Skip to main content

orch/engine/runner/
mod.rs

1//! Task runner — executes tasks using AI agents.
2//!
3//! Replaces `run_task.sh` with pure Rust. The runner:
4//! 1. Sets up a git worktree for isolation
5//! 2. Builds context (project instructions, skills, comments)
6//! 3. Renders prompts and spawns the agent in tmux
7//! 4. Waits for completion, collects and classifies the response
8//! 5. Handles errors (reroute on limits, fallback agents)
9//! 6. Auto-commits, pushes, and creates PRs
10//! 7. Updates status labels and posts result comments
11//!
12//! Submodules handle the heavy lifting:
13//! - [`task_init`] — guard checks, worktree setup, and invocation building
14//! - [`session`] — tmux session lifecycle and output collection
15//! - [`response_handler`] — success path: commit, push, PR, token storage
16//! - [`fallback`] — error classification and recovery strategies
17
18pub mod agent;
19pub mod agents;
20pub mod commit;
21pub mod context;
22pub mod cow;
23pub mod diff;
24pub mod direct;
25pub mod fallback;
26pub mod git_ops;
27pub mod response;
28pub mod response_handler;
29pub mod sandbox;
30pub mod session;
31pub mod task_init;
32pub mod worktree;
33
34use crate::backends::{ExternalBackend, ExternalTask, Status};
35use crate::config;
36use crate::engine;
37use crate::engine::cleanup;
38use crate::engine::cooldown;
39use crate::engine::router::RouteResult;
40use crate::engine::tasks;
41use crate::engine::tasks::is_internal_id;
42#[cfg(not(test))]
43use crate::github::http;
44use crate::github::http::GhHttp;
45use crate::home;
46use crate::parser;
47use crate::parser::AgentResponse;
48use crate::parser::Delegation;
49use crate::status_heuristics;
50use crate::store;
51use crate::store::InsertTaskMetric;
52use crate::store::RunTokenUsage;
53use crate::store::TaskStatus;
54use crate::store::TaskStore;
55use crate::tmux::TmuxManager;
56use anyhow::Context;
57use chrono::Utc;
58pub use response::WeightSignal;
59use serde_json::Value;
60use std::collections::HashSet;
61use std::env;
62use std::fmt;
63use std::fmt::Debug;
64use std::fmt::Formatter;
65use std::path::PathBuf;
66use std::slice;
67use std::sync::Arc;
68use tokio::fs;
69use tokio::task;
70
71/// Fully materialized task-run audit data.
72#[derive(Debug, Clone)]
73pub struct RunAudit {
74    pub stdout: String,
75    pub stderr: String,
76    pub parsed_response: String,
77    pub outcome: String,
78    pub error: String,
79    pub input_tokens: u64,
80    pub output_tokens: u64,
81    pub total_cost_usd: f64,
82    pub duration_secs: f64,
83}
84
85/// Result returned by `run()` so callers can persist the audit trail.
86#[derive(Debug, Clone)]
87pub struct RunExecution {
88    pub status: String,
89    pub exit_code: Option<i32>,
90    pub audit: RunAudit,
91}
92
93fn serialize_parsed_response(
94    parse_result: &Result<agents::ParsedResponse, agents::AgentError>,
95    raw_stdout: &str,
96) -> String {
97    match parse_result {
98        Ok(parsed) => serde_json::json!({
99            "response": parsed.response,
100            "input_tokens": parsed.input_tokens,
101            "output_tokens": parsed.output_tokens,
102            "duration_ms": parsed.duration_ms,
103        })
104        .to_string(),
105        Err(agents::AgentError::InvalidResponse { raw }) => {
106            serde_json::json!({"raw": raw}).to_string()
107        }
108        Err(_) => serde_json::json!({"raw": raw_stdout}).to_string(),
109    }
110}
111
112fn classify_run_outcome(
113    status: &str,
114    parse_result: &Result<agents::ParsedResponse, agents::AgentError>,
115    push_failed: bool,
116) -> &'static str {
117    if push_failed {
118        return "push_failed";
119    }
120    match parse_result {
121        Err(agents::AgentError::Timeout { .. }) => "timeout",
122        Err(agents::AgentError::Interrupted { .. }) => "interrupted",
123        // Generic rate-limit patterns (e.g. "usage limit") also match billing-cycle
124        // exhaustion messages, so consult the credit-exhaustion detector first —
125        // otherwise Kimi-style "usage limit for this billing cycle" errors are
126        // stored as a plain "rate_limit" outcome (issue #3529).
127        Err(agents::AgentError::RateLimit { message }) => {
128            cooldown::detect_credit_exhaustion_outcome(message).unwrap_or("rate_limit")
129        }
130        Err(agents::AgentError::Auth { message }) => {
131            cooldown::detect_credit_exhaustion_outcome(message).unwrap_or("failed")
132        }
133        Err(agents::AgentError::InvalidResponse { .. }) => "parse_error",
134        Ok(_)
135            if matches!(
136                status,
137                "done" | "completed" | "in_progress" | "in_review" | "needs_review" | "routed"
138            ) =>
139        {
140            "success"
141        }
142        Ok(_) if status == "blocked" => "blocked",
143        // Non-canonical status that clearly reads as completion — success, not
144        // "unrecognized status" (#3627).
145        Ok(_) if status_heuristics::status_looks_like_descriptive_completion(status) => "success",
146        Err(_) | Ok(_) => "failed",
147    }
148}
149
150fn classify_run_error_type(last_error: &str) -> &'static str {
151    if last_error.is_empty() {
152        // No error: agent completed successfully and created a PR waiting for review
153        "success"
154    } else if last_error.contains("timeout") {
155        "timeout"
156    } else if last_error.contains("interrupted") {
157        "interrupted"
158    } else if last_error.contains("billing cycle") || last_error.contains("monthly quota") {
159        "billing_cycle_exhausted"
160    } else if last_error.contains("rate limit") || last_error.contains("usage limit") {
161        "rate_limit"
162    } else if last_error.contains("auth") {
163        "auth_error"
164    } else if last_error.contains("push failed") {
165        "push_failed"
166    } else if last_error.contains("No commits between") || last_error.contains("no commits") {
167        "no_commits"
168    } else if last_error.contains("create PR failed")
169        || last_error.contains("failed to create pull request")
170        || last_error.contains("pull request creation failed")
171    {
172        "pr_failed"
173    } else if last_error.contains("invalid response") || last_error.contains("parse error") {
174        "parse_error"
175    } else if last_error.contains("exceeded max attempts") {
176        "max_attempts"
177    } else {
178        let lower = last_error.to_ascii_lowercase();
179        if lower.contains("build failed")
180            || lower.contains("tests failed")
181            || lower.contains("test failed")
182            || lower.contains("test suite failed")
183            || lower.contains("lint failed")
184        {
185            "ci_failure"
186        } else {
187            "failed"
188        }
189    }
190}
191
192fn extract_run_tokens(
193    parse_result: &Result<agents::ParsedResponse, agents::AgentError>,
194) -> (u64, u64) {
195    match parse_result {
196        Ok(parsed) => (
197            parsed
198                .input_tokens
199                .or(parsed.response.input_tokens)
200                .unwrap_or(0),
201            parsed
202                .output_tokens
203                .or(parsed.response.output_tokens)
204                .unwrap_or(0),
205        ),
206        Err(_) => (0, 0),
207    }
208}
209
210#[expect(clippy::cast_precision_loss, reason = "a run duration in seconds")]
211fn run_duration_secs(started_at: &chrono::DateTime<Utc>) -> f64 {
212    (Utc::now() - *started_at).num_milliseconds() as f64 / 1000.0
213}
214
215struct RunAuditInput<'a> {
216    task_id: &'a str,
217    status: &'a str,
218    parse_result: &'a Result<agents::ParsedResponse, agents::AgentError>,
219    raw_stdout: &'a str,
220    raw_stderr: &'a str,
221    started_at: &'a chrono::DateTime<Utc>,
222    error_override: Option<String>,
223    elapsed_secs: Option<u64>,
224    push_failed: bool,
225}
226
227fn parse_success_output(
228    task_id: &str,
229    agent_name: &str,
230    agent_runner: &dyn agents::AgentRunner,
231    raw_stdout: &str,
232) -> Result<agents::ParsedResponse, agents::AgentError> {
233    // Quick NDJSON terminal_reason:completed shortcut: when the agent
234    // emitted a session envelope that explicitly signals completion we
235    // prefer to interpret that as a successful run (or at least a
236    // soft/empty success) rather than immediately classifying an error
237    // from the trailing process output.  Do not override explicit
238    // in-envelope `is_error: true` signals.
239    if raw_stdout.contains("\"terminal_reason\":\"completed\"")
240        && !raw_stdout.contains("\"is_error\":true")
241    {
242        // Try to recover a structured response from assistant messages first
243        let assistant_text = agents::collect_assistant_messages_text(agent_name, raw_stdout);
244        if !assistant_text.is_empty() {
245            if let Ok(response) = parser::parse(&assistant_text) {
246                tracing::debug!(
247                    task_id,
248                    agent = agent_name,
249                    "found AgentResponse JSON in earlier assistant message (terminal_reason:completed)"
250                );
251                return Ok(agents::ParsedResponse {
252                    response,
253                    input_tokens: None,
254                    output_tokens: None,
255                    duration_ms: None,
256                });
257            }
258            if let Some(response) = agents::synthesize_response_from_text(&assistant_text) {
259                tracing::warn!(
260                    task_id,
261                    agent = agent_name,
262                    "synthesizing response from assistant message due to terminal_reason:completed"
263                );
264                return Ok(agents::ParsedResponse {
265                    response,
266                    input_tokens: None,
267                    output_tokens: None,
268                    duration_ms: None,
269                });
270            }
271        }
272        // If we couldn't recover structured data from earlier assistant
273        // messages, fall through and let the regular NDJSON envelope parsing
274        // / agent-specific parsers handle the terminal_reason case. This
275        // preserves the existing behavior where an empty `result` field
276        // combined with exit != 0 becomes an InvalidResponse that is then
277        // interpreted by parse_session_output (see tests).
278    }
279
280    // 1. Try to use the unified agent result extractor first
281    if let Some(agent_result) = agents::find_agent_result(agent_name, raw_stdout) {
282        // Treat is_error as authoritative for the success path
283        if agent_result.is_error {
284            // Classify the error from the agent's result text rather than
285            // blindly returning AgentFailed — this preserves rate-limit, auth,
286            // and other actionable error types for proper cooldown/reroute.
287            // Without this, e.g. a claude session-limit error with is_error=true
288            // would be downgraded to AgentFailed, losing the RateLimit signal
289            // that the fallback chain needs for UsageLimit recovery (issue #3292).
290            if let Some(e) = agents::patterns::detect_rate_limit(&agent_result.result_text) {
291                return Err(e);
292            }
293            if let Some(e) = agents::patterns::detect_auth_error(&agent_result.result_text) {
294                return Err(e);
295            }
296            if let Some(e) = agents::patterns::detect_model_unavailable(&agent_result.result_text) {
297                return Err(e);
298            }
299            return Err(agents::AgentError::AgentFailed {
300                message: agent_result.result_text,
301            });
302        }
303
304        // Try to parse the inner result text as standard JSON
305        if let Ok(response) = parser::parse(&agent_result.result_text) {
306            return Ok(agents::ParsedResponse {
307                response,
308                input_tokens: agent_result.input_tokens,
309                output_tokens: agent_result.output_tokens,
310                duration_ms: agent_result.duration_ms,
311            });
312        }
313        // Strategy 2: scan all earlier assistant messages in the NDJSON
314        // stream for valid AgentResponse JSON. Agents often emit the
315        // JSON in an intermediate turn and then summarise in prose as
316        // their final output, so the `type:result` text may be pure
317        // narrative while a prior assistant turn contains the JSON.
318        let assistant_text = agents::collect_assistant_messages_text(agent_name, raw_stdout);
319        if !assistant_text.is_empty()
320            && let Ok(response) = parser::parse(&assistant_text)
321        {
322            tracing::debug!(
323                task_id,
324                agent = agent_name,
325                "found AgentResponse JSON in earlier assistant message"
326            );
327            return Ok(agents::ParsedResponse {
328                response,
329                input_tokens: agent_result.input_tokens,
330                output_tokens: agent_result.output_tokens,
331                duration_ms: agent_result.duration_ms,
332            });
333        }
334
335        // Only fall back to text synthesis when parsing fails
336        if let Some(response) = agents::synthesize_response_from_text(&agent_result.result_text) {
337            tracing::warn!(
338                task_id,
339                agent = agent_name,
340                "parse failed on agent result, synthesizing response from plain text"
341            );
342            return Ok(agents::ParsedResponse {
343                response,
344                input_tokens: agent_result.input_tokens,
345                output_tokens: agent_result.output_tokens,
346                duration_ms: agent_result.duration_ms,
347            });
348        }
349
350        // NDJSON envelope fallback: the envelope explicitly reports
351        // success (is_error=false) but the result content is domain
352        // JSON or plain prose that doesn't match AgentResponse schema
353        // and doesn't contain completion keywords.  Rather than
354        // returning InvalidResponse (which causes the task to re-run),
355        // synthesize a minimal "done" response from the result text.
356        //
357        // Guard against kimi/minimax edge case: when terminal_reason=completed
358        // but result text is empty, synthesize returns None, parse_success_output
359        // returns InvalidResponse, and parse_session_output can intercept it
360        // before classify_error. Requiring non-empty result here means the
361        // envelope fallback only fires when we have actual text to report.
362        if !agent_result.is_error
363            && result_envelope_completed(raw_stdout)
364            && !agent_result.result_text.is_empty()
365        {
366            let summary_end = agents::truncate_at_char_boundary(&agent_result.result_text, 500);
367            let summary = agent_result.result_text[..summary_end].to_string();
368            tracing::warn!(
369                task_id,
370                agent = agent_name,
371                "NDJSON envelope indicates success but result lacks AgentResponse \
372                 schema — synthesizing done response"
373            );
374            return Ok(agents::ParsedResponse {
375                response: AgentResponse {
376                    status: "done".to_string(),
377                    summary,
378                    ..Default::default()
379                },
380                input_tokens: agent_result.input_tokens,
381                output_tokens: agent_result.output_tokens,
382                duration_ms: agent_result.duration_ms,
383            });
384        }
385    }
386
387    // 2. Fall back to generic agent-specific parser if extraction failed
388    match agent_runner.parse_response(raw_stdout) {
389        Ok(parsed) => Ok(parsed),
390        Err(agents::AgentError::InvalidResponse { raw }) => {
391            if let Some(response) = agents::synthesize_response_from_text(&raw) {
392                tracing::warn!(
393                    task_id,
394                    agent = agent_name,
395                    "parse failed, synthesizing response from plain text"
396                );
397                // Try to extract token metadata from the original raw NDJSON
398                // output (not the truncated/extracted text from the error), so
399                // cost tracking isn't lost for agents that return free-form text.
400                let agent_result = agents::find_agent_result(agent_name, raw_stdout);
401                let input_tokens = agent_result.as_ref().and_then(|r| r.input_tokens);
402                let output_tokens = agent_result.as_ref().and_then(|r| r.output_tokens);
403                let duration_ms = agent_result.as_ref().and_then(|r| r.duration_ms);
404                Ok(agents::ParsedResponse {
405                    response,
406                    input_tokens,
407                    output_tokens,
408                    duration_ms,
409                })
410            } else {
411                Err(agents::AgentError::InvalidResponse { raw })
412            }
413        }
414        Err(err) => Err(err),
415    }
416}
417
418/// Whether the last `type: result` line reports `subtype: success` or
419/// `terminal_reason: completed`. Grok omits `terminal_reason`.
420fn result_envelope_line(raw_stdout: &str) -> Option<serde_json::Value> {
421    raw_stdout
422        .lines()
423        .rev()
424        .filter_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
425        .find(|v| v.get("type").and_then(|t| t.as_str()) == Some("result"))
426}
427
428fn has_result_envelope(raw_stdout: &str) -> bool {
429    result_envelope_line(raw_stdout).is_some()
430}
431
432fn result_envelope_completed(raw_stdout: &str) -> bool {
433    result_envelope_line(raw_stdout).is_some_and(|v| {
434        v.get("subtype").and_then(|t| t.as_str()) == Some("success")
435            || v.get("terminal_reason").and_then(|t| t.as_str()) == Some("completed")
436    })
437}
438
439// A timeout kill has no result envelope. Salvage only the wrap-up shape: a successful
440// `git commit`, then only shell calls that all returned ok. Returns the last assistant text
441fn salvage_wrapup(raw: &str) -> Option<String> {
442    // (tool name, command, finished ok) in call order
443    let mut calls: Vec<(String, String, Option<bool>)> = Vec::new();
444    let mut last_text = String::new();
445    for v in raw
446        .lines()
447        .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
448    {
449        let blocks = v.pointer("/message/content").and_then(|c| c.as_array());
450        for b in blocks.into_iter().flatten() {
451            match b.get("type").and_then(|t| t.as_str()) {
452                Some("tool_use") => calls.push((
453                    b["name"].as_str().unwrap_or("").to_string(),
454                    b.pointer("/input/command")
455                        .and_then(|c| c.as_str())
456                        .unwrap_or("")
457                        .to_string(),
458                    None,
459                )),
460                Some("text") if v["type"] == "assistant" => {
461                    last_text = b["text"].as_str().unwrap_or("").to_string();
462                }
463                Some("tool_result") => {
464                    let ok = b.get("is_error").and_then(serde_json::Value::as_bool) != Some(true);
465                    // results arrive in call order, fill the first unanswered call
466                    if let Some(c) = calls.iter_mut().find(|c| c.2.is_none()) {
467                        c.2 = Some(ok);
468                    }
469                }
470                _ => {}
471            }
472        }
473    }
474    let commit = calls
475        .iter()
476        .rposition(|(_, cmd, ok)| cmd.contains("git commit") && *ok == Some(true))?;
477    let tail_ok = calls[commit..].iter().all(|(name, _, ok)| {
478        matches!(name.as_str(), "Bash" | "run_terminal_cmd") && *ok == Some(true)
479    });
480    let lower = last_text.to_lowercase();
481    if !tail_ok || lower.contains("still running") || lower.contains("in progress") {
482        return None;
483    }
484    Some(last_text.chars().take(500).collect())
485}
486
487/**
488 * Tries `parse_success_output` first when stdout is non-empty. If that succeeds,
489 * returns success regardless of exit code. If it fails and `exit_code != 0`,
490 * checks for `"terminal_reason":"completed"` or cost-telemetry-only stdout
491 * (`"costUSD":`) before falling back to `classify_error`, so valid sessions that
492 * exit 1 produce `InvalidResponse` (soft error, re-route) instead of a garbled
493 * `Unknown`/`Auth` error (hard failure with spurious cooldown)
494 **/
495pub fn parse_session_output(
496    task_id: &str,
497    agent_name: &str,
498    agent_runner: &dyn agents::AgentRunner,
499    session_output: &session::SessionOutput,
500) -> Result<agents::ParsedResponse, agents::AgentError> {
501    // Some agents may emit NDJSON/telemetry to stderr instead of stdout. Combine
502    // both streams so parse_success_output can inspect either place for the
503    // terminal_reason/cost telemetry envelope. Only skip parsing when both are
504    // empty so we still attempt recovery when stderr contains the envelope.
505    let combined_output = format!("{}{}", session_output.raw_stdout, session_output.raw_stderr);
506    if !(session_output.raw_stdout.is_empty() && session_output.raw_stderr.is_empty()) {
507        match parse_success_output(task_id, agent_name, agent_runner, &combined_output) {
508            Ok(parsed) => return Ok(parsed),
509            Err(err) => {
510                if session_output.exit_code == 124
511                    && let Some(summary) = salvage_wrapup(&combined_output)
512                {
513                    tracing::warn!(
514                        task_id,
515                        agent = agent_name,
516                        "timed out after commit during wrap-up, salvaging as done"
517                    );
518                    return Ok(agents::ParsedResponse {
519                        response: AgentResponse {
520                            status: "done".to_string(),
521                            summary,
522                            ..Default::default()
523                        },
524                        input_tokens: None,
525                        output_tokens: None,
526                        duration_ms: None,
527                    });
528                }
529                // If parse_success_output already returned a classified error
530                // (RateLimit, AgentFailed, Timeout, Auth, etc.), propagate it
531                // directly — only InvalidResponse (unparseable output) should
532                // be intercepted by the terminal_reason:completed / cost-telemetry
533                // guard below. Without this, e.g. a claude session-limit error
534                // with terminal_reason=completed would be downgraded from RateLimit
535                // to InvalidResponse → Failed, losing the correct cooldown/reroute.
536                if !matches!(err, agents::AgentError::InvalidResponse { .. }) {
537                    return Err(err);
538                }
539                if session_output.exit_code != 0 {
540                    // kimi/minimax/glm sometimes emit valid NDJSON with
541                    // "terminal_reason":"completed" or cost-only telemetry
542                    // ("costUSD":) but exit 1 due to a post-session hook or
543                    // minor CLI issue. Scanning the tail would just grab JSON
544                    // metadata, producing a garbled error. Detect either
545                    // completion signal and treat as a soft parse error so the
546                    // task re-routes instead of hitting a false cooldown from
547                    // Unknown or Auth error variants. Use the combined output
548                    // (stdout+stderr) so envelopes written to stderr are
549                    // observed.
550                    let combined = &combined_output;
551                    let is_completed_signal =
552                        combined.contains("\"terminal_reason\":\"completed\"");
553                    // GLM emits a cost-telemetry JSON object (containing
554                    // "costUSD":) as its final stdout/stderr line on clean
555                    // exit even when the process exits with code 1. If the
556                    // only content is such telemetry (no is_error=true in the
557                    // envelope), treat it the same as terminal_reason:completed.
558                    let is_cost_telemetry_only = combined.contains("\"costUSD\":")
559                        && !combined.contains("\"is_error\":true");
560                    if is_completed_signal || is_cost_telemetry_only {
561                        let raw_tail = agents::patterns::safe_tail(combined, 300);
562                        return Err(agents::AgentError::InvalidResponse {
563                            raw: raw_tail.to_string(),
564                        });
565                    }
566                    return Err(agent_runner.classify_error_with_elapsed(
567                        session_output.exit_code,
568                        &session_output.raw_stdout,
569                        &session_output.raw_stderr,
570                        session_output.elapsed_secs,
571                    ));
572                }
573                if !has_result_envelope(&combined_output) {
574                    return Err(agents::AgentError::Interrupted {
575                        message: "exited without a result envelope".to_string(),
576                    });
577                }
578                return Err(err);
579            }
580        }
581    }
582
583    if session_output.exit_code != 0 {
584        return Err(agent_runner.classify_error_with_elapsed(
585            session_output.exit_code,
586            &session_output.raw_stdout,
587            &session_output.raw_stderr,
588            session_output.elapsed_secs,
589        ));
590    }
591
592    Err(agents::AgentError::Unknown {
593        exit_code: session_output.exit_code,
594        message: "empty-output-exit0: opencode returned exit 0 with empty stdout".to_string(),
595    })
596}
597
598/// Task runner configuration.
599pub struct TaskRunner {
600    /// Repository slug (owner/repo)
601    repo: String,
602    /// Path to the orch home directory
603    orch_home: PathBuf,
604    /// Unified task store for run audit trail
605    store: Option<Arc<TaskStore>>,
606}
607
608impl Debug for TaskRunner {
609    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
610        f.debug_struct("TaskRunner")
611            .field("repo", &self.repo)
612            .field("orch_home", &self.orch_home)
613            .finish_non_exhaustive()
614    }
615}
616
617impl TaskRunner {
618    pub fn new(repo: String) -> Self {
619        let orch_home = home::orch_home().unwrap_or_else(|e| {
620            tracing::warn!(error = %e, "orch_home() failed — falling back to /tmp/.orch");
621            PathBuf::from("/tmp").join(".orch")
622        });
623
624        Self {
625            repo,
626            orch_home,
627            store: None,
628        }
629    }
630
631    /// Set the unified task store for run audit trail.
632    #[must_use]
633    pub fn with_store(mut self, store: Arc<TaskStore>) -> Self {
634        self.store = Some(store);
635        self
636    }
637
638    /// Read a field from the task store.
639    async fn get_field(&self, task_id: &str, field: &str) -> Option<String> {
640        let task = store::opt_store_get_task(&self.store, &self.repo, task_id).await?;
641        match field {
642            "route_reason" => Some(task.route_reason),
643            "worktree" => Some(task.worktree),
644            "attempts" => Some(task.attempts.to_string()),
645            "delegations" => serde_json::to_string(&task.delegations).ok(),
646            "summary" => Some(task.summary),
647            "last_error" => Some(task.last_error),
648            "agent" => task.agent,
649            _ => None,
650        }
651    }
652
653    async fn build_run_audit(&self, input: RunAuditInput<'_>) -> RunAudit {
654        // Filter empty strings so that a blank last_error does not shadow the
655        // actual parse_result error.  Without this, `Option::or(Some(""))` would
656        // return `Some("")` and prevent the `unwrap_or_else` fallback from ever
657        // running, causing task_runs.error to be written as "" even when the
658        // agent returned a classifiable error (fixes issue #2527).
659        let last_error = self
660            .get_field(input.task_id, "last_error")
661            .await
662            .filter(|s| !s.is_empty());
663        let outcome =
664            classify_run_outcome(input.status, input.parse_result, input.push_failed).to_string();
665
666        let error = if outcome == "success" {
667            String::new()
668        } else {
669            // Prefer this run's parsed error over a last_error left by a previous
670            // agent's failover. last_error stays the Err/EarlyReturn fallback (#3851)
671            let error = input
672                .error_override
673                .as_deref()
674                .filter(|s| !s.is_empty())
675                .map(str::to_string)
676                .or_else(|| match input.parse_result {
677                    Ok(parsed) => parsed
678                        .response
679                        .error
680                        .as_deref()
681                        .filter(|s| !s.is_empty())
682                        .map(str::to_string),
683                    Err(_) => None,
684                })
685                .or(last_error)
686                .unwrap_or_else(|| match input.parse_result {
687                    Ok(_) => String::new(),
688                    Err(err) => err.to_string(),
689                });
690
691            if error.is_empty() {
692                if input.status == "blocked" {
693                    match input.parse_result {
694                        Ok(parsed) if !parsed.response.summary.is_empty() => {
695                            parsed.response.summary.clone()
696                        }
697                        _ => "task blocked without explicit error".to_string(),
698                    }
699                } else if matches!(
700                    input.status,
701                    "done"
702                        | "completed"
703                        | "in_progress"
704                        | "in_review"
705                        | "needs_review"
706                        | "routed"
707                        | "new"
708                ) {
709                    // Canonical non-success statuses have no error message (e.g. "routed").
710                    String::new()
711                } else {
712                    // Non-canonical status with no error message — surface the status
713                    // explicitly so operators can identify which statuses need to be
714                    // added to the normalization map in parser.rs.
715                    format!("unrecognized status: {}", input.status)
716                }
717            } else {
718                error
719            }
720        };
721
722        let total_cost_usd = match &self.store {
723            Some(_) => {
724                store::get_cost_estimate(&self.store, &self.repo, input.task_id)
725                    .await
726                    .total_cost_usd
727            }
728            None => 0.0,
729        };
730
731        let (input_tokens, output_tokens) = extract_run_tokens(input.parse_result);
732
733        #[expect(clippy::cast_precision_loss, reason = "a run duration in seconds")]
734        let duration_secs = input
735            .elapsed_secs
736            .map_or_else(|| run_duration_secs(input.started_at), |s| s as f64);
737
738        RunAudit {
739            stdout: input.raw_stdout.to_string(),
740            stderr: input.raw_stderr.to_string(),
741            parsed_response: serialize_parsed_response(input.parse_result, input.raw_stdout),
742            outcome,
743            error,
744            input_tokens,
745            output_tokens,
746            total_cost_usd,
747            duration_secs,
748        }
749    }
750
751    /// The blocked outcome for a sandbox that cannot start. No agent ran, so none is cooled down
752    async fn sandbox_blocked(&self, task_id: &str, reason: String) -> RunExecution {
753        tracing::error!(task_id, error = %reason, "blocking task");
754        if let Err(e) = store::store_set_result(
755            &self.store,
756            &self.repo,
757            task_id,
758            &[
759                ("last_error", serde_json::json!(reason)),
760                ("block_reason", serde_json::json!(reason)),
761            ],
762        )
763        .await
764        {
765            tracing::warn!(task_id, err = %e, "failed to write the sandbox block_reason");
766        }
767        RunExecution {
768            status: "blocked".to_string(),
769            exit_code: None,
770            audit: RunAudit {
771                stdout: String::new(),
772                stderr: String::new(),
773                parsed_response: String::new(),
774                outcome: "failed".to_string(),
775                error: reason,
776                input_tokens: 0,
777                output_tokens: 0,
778                total_cost_usd: 0.0,
779                duration_secs: 0.0,
780            },
781        }
782    }
783
784    /// Run a task through the full execution pipeline.
785    ///
786    /// Returns `Ok(None)` if the runner guard skipped the task (caller should not
787    /// post stale results). Returns `Ok(Some(status))` with the final task status.
788    pub async fn run(
789        &self,
790        task_id: &str,
791        agent: Option<&str>,
792        model: Option<&str>,
793        backend: Option<&dyn ExternalBackend>,
794        started_at: &chrono::DateTime<Utc>,
795    ) -> anyhow::Result<Option<RunExecution>> {
796        tracing::info!(
797            task_id,
798            agent = agent.unwrap_or("default"),
799            model = model.unwrap_or("default"),
800            "starting task execution"
801        );
802
803        // Check task guards; returns outcome indicating whether to proceed.
804        let attempts = match task_init::check_guards(task_id, &self.repo, &self.store).await {
805            Ok(task_init::GuardOutcome::Proceed(a)) => a,
806            Ok(task_init::GuardOutcome::Skip) => {
807                return Ok(None);
808            }
809            Ok(task_init::GuardOutcome::MaxAttempts) => {
810                // The caller marks the task blocked from the returned status, a task that ran out
811                // of attempts did not finish, so it must never reach done
812                if backend.is_some() {
813                    // If this was label-forced to a specific agent, remove the agent label
814                    // so that /retry can route to a different agent instead of looping.
815                    let route_reason = self
816                        .get_field(task_id, "route_reason")
817                        .await
818                        .unwrap_or_default();
819                    if route_reason.starts_with("label agent:") {
820                        let agent_label = route_reason.trim_start_matches("label ");
821                        let gh = GhHttp::new()?;
822                        match gh.remove_label(&self.repo, task_id, agent_label).await {
823                            Err(e) => {
824                                tracing::warn!(task_id, label = agent_label, err = %e, "failed to remove agent label after max attempts");
825                            }
826                            _ => {
827                                tracing::info!(
828                                    task_id,
829                                    label = agent_label,
830                                    "removed forced agent label after max attempts — /retry will use free routing"
831                                );
832                            }
833                        }
834                    }
835                }
836                return Ok(Some(RunExecution {
837                    status: "blocked".to_string(),
838                    exit_code: None,
839                    audit: RunAudit {
840                        stdout: String::new(),
841                        stderr: String::new(),
842                        parsed_response: String::new(),
843                        outcome: "failed".to_string(),
844                        error: "max attempts reached".to_string(),
845                        input_tokens: 0,
846                        output_tokens: 0,
847                        total_cost_usd: 0.0,
848                        duration_secs: 0.0,
849                    },
850                }));
851            }
852            Err(e) => return Err(e),
853        };
854
855        // Resolve project directory
856        let project_dir = self.resolve_project_dir().await?;
857
858        // Prepare task: worktree, context, prompts, and agent invocation
859        let init = task_init::prepare_task(
860            task_id,
861            agent,
862            model,
863            backend,
864            &self.repo,
865            &project_dir,
866            attempts,
867            &self.store,
868        )
869        .await?;
870
871        // A sandbox that cannot start blocks the task, which never runs without it. It is the
872        // operator's setup that fails, so no agent or model is cooled down
873        if init.invocation.sandbox
874            && let Err(e) = sandbox::argv(
875                &init.invocation,
876                &env::temp_dir().join("orch-sandbox-preflight"),
877            )
878            .await
879        {
880            return Ok(Some(self.sandbox_blocked(task_id, e.to_string()).await));
881        }
882
883        // Run agent session: spawn in tmux, wait for completion, collect output
884        let (tmux, tmux_session, session_output) = session::run_agent_session(
885            task_id,
886            &init.invocation,
887            &init.attempt_dir,
888            &self.orch_home,
889        )
890        .await;
891
892        // The pre-flight policy passed but the one built at spawn did not: block the same way
893        if let Some(reason) = session_output.sandbox_error.clone() {
894            return Ok(Some(self.sandbox_blocked(task_id, reason).await));
895        }
896
897        // Touch updated_at immediately after session exit so the stuck-task recovery
898        // timer resets before response_handler post-processing (git push, PR creation,
899        // status update) begins.  Without this, a session that ran longer than
900        // no_session_stuck_timeout (default 10 min) would be re-dispatched by the tick
901        // while the runner is still writing results.
902        store::store_touch_updated_at(&self.store, &self.repo, task_id).await;
903
904        // If silence detection (tick phase1b) already rerouted this task while the
905        // session was running, skip all fallback/review processing. Without this check the
906        // runner would call `fallback::handle_error` on the empty output, exhaust fallback
907        // agents, and mark the task `needs_review` — overwriting the rerouted status and
908        // triggering a spurious review cycle.
909        // Silence detection now sets Routed (with fallback agent) or NeedsReview (no fallback).
910        // Check for both, plus New for backward compatibility.
911        if let Some(stored) = store::opt_store_get_task(&self.store, &self.repo, task_id).await {
912            let silence_reset = matches!(
913                stored.status,
914                TaskStatus::New | TaskStatus::Routed | TaskStatus::NeedsReview
915            );
916            if silence_reset {
917                let status_str = stored.status.as_str();
918                tracing::info!(
919                    task_id,
920                    status = status_str,
921                    "task already reset by silence detection — skipping fallback processing"
922                );
923                session::cleanup_session(task_id, &tmux, &tmux_session).await;
924                let silence_err = agents::patterns::classify_from_text(0, "silence detected");
925                let silence_parse: Result<agents::ParsedResponse, agents::AgentError> =
926                    Err(silence_err);
927                let audit = self
928                    .build_run_audit(RunAuditInput {
929                        task_id,
930                        status: status_str,
931                        parse_result: &silence_parse,
932                        raw_stdout: &session_output.raw_stdout,
933                        raw_stderr: &session_output.raw_stderr,
934                        started_at,
935                        error_override: Some(format!("silence detection set task to {status_str}")),
936                        elapsed_secs: session_output.elapsed_secs,
937                        push_failed: false,
938                    })
939                    .await;
940                return Ok(Some(RunExecution {
941                    status: status_str.to_string(),
942                    exit_code: Some(session_output.exit_code),
943                    audit,
944                }));
945            }
946        }
947
948        // Log raw output for debugging agent failures
949        let stdout_len = session_output.raw_stdout.len();
950        let stderr_len = session_output.raw_stderr.len();
951        let stdout_tail: String = session_output
952            .raw_stdout
953            .chars()
954            .rev()
955            .take(500)
956            .collect::<String>()
957            .chars()
958            .rev()
959            .collect();
960        let stderr_tail: String = session_output
961            .raw_stderr
962            .chars()
963            .rev()
964            .take(500)
965            .collect::<String>()
966            .chars()
967            .rev()
968            .collect();
969        tracing::info!(
970            task_id,
971            exit_code = session_output.exit_code,
972            stdout_len,
973            stderr_len,
974            stdout_tail = %stdout_tail,
975            stderr_tail = %stderr_tail,
976            "agent raw output"
977        );
978
979        // Use agent-specific parsing where possible. Previously we only attempted
980        // parse_success_output when exit_code == 0 which caused runs that emitted
981        // valid NDJSON but exited non-zero (kimi / minimax) to be classed as
982        // failures. Try parsing success output first when stdout is present; if
983        // parsing succeeds treat as success regardless of exit code. Only fall
984        // back to error classification when parsing fails and the process exit
985        // code indicates an error.
986        let agent_runner = agents::get_runner(&init.agent_name);
987        let parse_result =
988            parse_session_output(task_id, &init.agent_name, &*agent_runner, &session_output);
989
990        // Write structured result.json for deterministic testing and debugging
991        response_handler::write_result_json(
992            &init.attempt_dir,
993            task_id,
994            &init.agent_name,
995            init.model_name.as_deref(),
996            session_output.exit_code,
997            init.new_attempts,
998            &parse_result,
999            &session_output.raw_stdout,
1000            &session_output.raw_stderr,
1001        )
1002        .await;
1003
1004        // Handle outcome: success or error recovery
1005        // `fallback_error` carries the attributed error message from `handle_error()` when
1006        // the Continue path is taken.  It is passed as `error_override` to `build_run_audit`
1007        // so that stale `last_error` values from a previous agent's run are never written
1008        // into the current run's audit record (fixes misattribution bug: task_run shows
1009        // agent=kimi but error="minimax timed out...").
1010        let (final_status, push_failed, fallback_error) = match parse_result {
1011            Ok(ref parsed) => {
1012                let (status, push_failed) = response_handler::handle_success(
1013                    task_id,
1014                    parsed.clone(),
1015                    &init.wt,
1016                    &init.task_title,
1017                    &init.agent_name,
1018                    init.model_name.as_deref(),
1019                    init.new_attempts,
1020                    &self.repo,
1021                    &self.store,
1022                    &session_output.raw_stdout,
1023                )
1024                .await?;
1025                (status, push_failed, None)
1026            }
1027            Err(ref agent_err) => {
1028                match fallback::handle_error(
1029                    task_id,
1030                    agent_err,
1031                    &init.agent_name,
1032                    &*agent_runner,
1033                    init.model_name.as_deref(),
1034                    init.complexity.as_deref(),
1035                    init.new_attempts,
1036                    &self.store,
1037                    &self.repo,
1038                )
1039                .await?
1040                {
1041                    fallback::ErrorHandleResult::EarlyReturn { status } => {
1042                        // Task rerouted — clean up tmux session and env secrets
1043                        session::cleanup_session(task_id, &tmux, &tmux_session).await;
1044                        // The fallback handler already writes a sanitized `last_error`
1045                        // into the store for EarlyReturn paths (reroutes / needs_review).
1046                        // Passing the raw agent_err here would record noisy provider
1047                        // payloads (e.g. full api_retry NDJSON). Leave error_override
1048                        // as None so build_run_audit will prefer the store's last_error
1049                        // (see src/engine/runner/fallback.rs which persists a compact
1050                        // summary for rate-limit and reroute cases).
1051                        let audit = self
1052                            .build_run_audit(RunAuditInput {
1053                                task_id,
1054                                status: &status,
1055                                parse_result: &parse_result,
1056                                raw_stdout: &session_output.raw_stdout,
1057                                raw_stderr: &session_output.raw_stderr,
1058                                started_at,
1059                                error_override: None,
1060                                elapsed_secs: session_output.elapsed_secs,
1061                                push_failed: false,
1062                            })
1063                            .await;
1064                        return Ok(Some(RunExecution {
1065                            status,
1066                            exit_code: Some(session_output.exit_code),
1067                            audit,
1068                        }));
1069                    }
1070                    fallback::ErrorHandleResult::Continue { status, error } => {
1071                        (status, false, Some(error))
1072                    }
1073                }
1074            }
1075        };
1076
1077        // Kill tmux session if still alive
1078        session::cleanup_session(task_id, &tmux, &tmux_session).await;
1079
1080        // For no-op done tasks (agent finished with no code changes and no PR),
1081        // clean up the worktree and branch immediately — tmux session is already
1082        // dead so the tmux guard won't block removal.
1083        if final_status.as_str() == "done"
1084            && let Some(ref st) = self.store
1085            && let Err(e) = cleanup::cleanup_task_worktree(task_id, &self.repo, st).await
1086        {
1087            tracing::warn!(task_id, error = ?e, "worktree cleanup failed after no-op done");
1088        }
1089
1090        // Return final status and the session exit code so callers can record it.
1091        // Use `fallback_error` as error_override when available: it carries the error message
1092        // attributed to the current agent (set by handle_error), preventing stale last_error
1093        // values from a prior run from contaminating this run's audit record.
1094        let audit = self
1095            .build_run_audit(RunAuditInput {
1096                task_id,
1097                status: &final_status,
1098                parse_result: &parse_result,
1099                raw_stdout: &session_output.raw_stdout,
1100                raw_stderr: &session_output.raw_stderr,
1101                started_at,
1102                error_override: fallback_error,
1103                elapsed_secs: session_output.elapsed_secs,
1104                push_failed,
1105            })
1106            .await;
1107
1108        Ok(Some(RunExecution {
1109            status: final_status.clone(),
1110            exit_code: Some(session_output.exit_code),
1111            audit,
1112        }))
1113    }
1114
1115    /// Record task execution metrics to the database.
1116    #[expect(
1117        clippy::too_many_arguments,
1118        reason = "a metric row needs the agent, model, route, timing and outcome together"
1119    )]
1120    async fn record_metrics(
1121        &self,
1122        task_id: &str,
1123        agent_name: &str,
1124        model_name: Option<&str>,
1125        route_result: Option<&RouteResult>,
1126        started_at: &chrono::DateTime<Utc>,
1127        attempts: u32,
1128        final_status: &str,
1129        error_type: Option<&str>,
1130    ) {
1131        let completed_at = Utc::now();
1132        #[expect(clippy::cast_precision_loss, reason = "a run duration in seconds")]
1133        let duration_seconds = (completed_at - *started_at).num_milliseconds() as f64 / 1000.0;
1134
1135        // Classify outcome based on the final status and optional error_type/last_error
1136        let outcome = match final_status {
1137            "done" | "in_progress" | "in_review" => "success",
1138            "new" => "rerouted",
1139            "needs_review" => classify_run_error_type(error_type.unwrap_or("")),
1140            _ => "unknown",
1141        };
1142
1143        let complexity = route_result.map(|r| r.complexity.clone());
1144        let files_changed = git_ops::count_changed_files(&PathBuf::from(
1145            self.get_field(task_id, "worktree")
1146                .await
1147                .unwrap_or_default(),
1148        ))
1149        .await
1150        .unwrap_or(0);
1151
1152        if let Some(ref store) = self.store {
1153            // Only set error_type for non-success outcomes
1154            let db_error_type: Option<String> = if outcome == "success" {
1155                None
1156            } else {
1157                Some(outcome.to_string())
1158            };
1159
1160            // Read cost data from store
1161            let usage = store::get_token_usage(&self.store, &self.repo, task_id).await;
1162            let cost = store::get_cost_estimate(&self.store, &self.repo, task_id).await;
1163            let input_tokens = if usage.input_tokens > 0 {
1164                Some(i64::try_from(usage.input_tokens).unwrap_or(i64::MAX))
1165            } else {
1166                None
1167            };
1168            let output_tokens = if usage.output_tokens > 0 {
1169                Some(i64::try_from(usage.output_tokens).unwrap_or(i64::MAX))
1170            } else {
1171                None
1172            };
1173            let input_cost = if cost.input_cost_usd > 0.0 {
1174                Some(cost.input_cost_usd)
1175            } else {
1176                None
1177            };
1178            let output_cost = if cost.output_cost_usd > 0.0 {
1179                Some(cost.output_cost_usd)
1180            } else {
1181                None
1182            };
1183            let total_cost = if cost.total_cost_usd > 0.0 {
1184                Some(cost.total_cost_usd)
1185            } else {
1186                None
1187            };
1188
1189            let metric = InsertTaskMetric {
1190                repo: &self.repo,
1191                task_id,
1192                agent: agent_name,
1193                model: model_name,
1194                complexity: complexity.as_deref(),
1195                outcome,
1196                duration_seconds,
1197                started_at,
1198                completed_at: &completed_at,
1199                attempts: i32::try_from(attempts.saturating_add(1)).unwrap_or(i32::MAX),
1200                files_changed: i32::try_from(files_changed).unwrap_or(i32::MAX),
1201                error_type: db_error_type.as_deref(),
1202                input_tokens,
1203                output_tokens,
1204                input_cost_usd: input_cost,
1205                output_cost_usd: output_cost,
1206                total_cost_usd: total_cost,
1207            };
1208
1209            if let Err(e) = store.insert_task_metric(&metric).await {
1210                tracing::error!(task_id, ?e, "failed to record task metrics");
1211            }
1212        }
1213    }
1214
1215    /// Run a task with full engine context (backend, tmux, capture).
1216    ///
1217    /// Called by the engine dispatch loop with richer context.
1218    /// Returns a `WeightSignal` for the engine to feed back to the router.
1219    pub async fn run_with_context(
1220        &self,
1221        task: &ExternalTask,
1222        backend: &Arc<dyn ExternalBackend>,
1223        _tmux: &Arc<TmuxManager>,
1224        route_result: Option<&RouteResult>,
1225    ) -> anyhow::Result<WeightSignal> {
1226        let task_id = &task.id.0;
1227        let agent = route_result.map(|r| r.agent.as_str());
1228        let agent_name = agent.unwrap_or("claude").to_string();
1229        let model = route_result.and_then(|r| r.model.as_deref());
1230
1231        // Record start time for metrics (before any work begins)
1232        let started_at = Utc::now();
1233
1234        // Store task info for prompt building (title/body already in store tasks table)
1235
1236        // Record run start in task_runs audit trail
1237        let run_audit_id = if let Some(ref store) = self.store {
1238            let attempt: i32 = self
1239                .get_field(task_id, "attempts")
1240                .await
1241                .and_then(|s| s.parse().ok())
1242                .unwrap_or(0)
1243                + 1;
1244            match store.resolve_task_id(&self.repo, task_id).await {
1245                Ok(Some(store_id)) => {
1246                    match store
1247                        .start_run(&crate::store::StartRun {
1248                            task_id: store_id,
1249                            attempt,
1250                            run_type: "agent",
1251                            agent: &agent_name,
1252                            model: model.unwrap_or(""),
1253                            command: &format!(
1254                                "{} --model {}",
1255                                agent_name,
1256                                model.unwrap_or("default")
1257                            ),
1258                            prompt: &task.body,
1259                        })
1260                        .await
1261                    {
1262                        Ok(run_id) => Some(run_id),
1263                        Err(e) => {
1264                            tracing::warn!(
1265                                task_id,
1266                                error = %e,
1267                                "failed to record run start in audit trail"
1268                            );
1269                            None
1270                        }
1271                    }
1272                }
1273                _ => None,
1274            }
1275        } else {
1276            None
1277        };
1278
1279        // Run the task.
1280        // Always finalize the run audit row, even on early-return/error paths.
1281        let run_result = match self
1282            .run(task_id, agent, model, Some(&**backend), &started_at)
1283            .await
1284        {
1285            Ok(run_result) => run_result,
1286            Err(e) => {
1287                if let Some(run_id) = run_audit_id
1288                    && let Some(ref store) = self.store
1289                    && let Err(complete_err) = store
1290                        .complete_run(&crate::store::CompleteRun {
1291                            run_id,
1292                            exit_code: Some(-1),
1293                            stdout: "",
1294                            stderr: "",
1295                            parsed: "",
1296                            outcome: "failed",
1297                            error: &e.to_string(),
1298                            tokens: RunTokenUsage::default(),
1299                        })
1300                        .await
1301                {
1302                    tracing::warn!(
1303                        task_id,
1304                        run_id,
1305                        error = %complete_err,
1306                        "failed to record run completion in audit trail after runner error"
1307                    );
1308                }
1309                return Err(e);
1310            }
1311        };
1312
1313        // If the runner guard skipped the task, do not re-post stale data as a new comment.
1314        let (status, exit_code_opt, run_audit) = if let Some(result) = run_result {
1315            (result.status, result.exit_code, result.audit)
1316        } else {
1317            if let Some(run_id) = run_audit_id
1318                && let Some(ref store) = self.store
1319                && let Err(e) = store
1320                    .complete_run(&crate::store::CompleteRun {
1321                        run_id,
1322                        exit_code: Some(-1),
1323                        stdout: "",
1324                        stderr: "",
1325                        parsed: "",
1326                        outcome: "aborted",
1327                        error: "run skipped by guard",
1328                        tokens: RunTokenUsage::default(),
1329                    })
1330                    .await
1331            {
1332                tracing::warn!(
1333                    task_id,
1334                    run_id,
1335                    error = %e,
1336                    "failed to record skipped run completion in audit trail"
1337                );
1338            }
1339            tracing::info!(task_id, "guard skipped task — not posting stale result");
1340            return Ok(WeightSignal::None);
1341        };
1342
1343        // Process delegations if the agent requested subtasks
1344        let delegations_raw = self
1345            .get_field(task_id, "delegations")
1346            .await
1347            .unwrap_or_default();
1348        if !delegations_raw.is_empty() {
1349            match serde_json::from_str::<Vec<Delegation>>(&delegations_raw) {
1350                Ok(delegations) if !delegations.is_empty() => {
1351                    self.process_delegations(task, &delegations, backend)
1352                        .await?;
1353                    if let Err(e) = store::store_set_result(
1354                        &self.store,
1355                        &self.repo,
1356                        task_id,
1357                        &[("delegations", serde_json::json!([]))],
1358                    )
1359                    .await
1360                    {
1361                        tracing::warn!(task_id, err = %e, "failed to clear delegations in store");
1362                    }
1363                }
1364                Ok(_) => {} // empty list, nothing to do
1365                Err(e) => {
1366                    tracing::error!(task_id, error = %e, "corrupt delegations JSON — clearing");
1367                    if let Err(e2) = store::store_set_result(
1368                        &self.store,
1369                        &self.repo,
1370                        task_id,
1371                        &[("delegations", serde_json::json!([]))],
1372                    )
1373                    .await
1374                    {
1375                        tracing::warn!(task_id, err = %e2, "failed to clear corrupt delegations in store");
1376                    }
1377                    if let Err(e2) = store::store_set_result(
1378                        &self.store,
1379                        &self.repo,
1380                        task_id,
1381                        &[(
1382                            "last_error",
1383                            serde_json::json!(format!("delegation parse failed: {e}")),
1384                        )],
1385                    )
1386                    .await
1387                    {
1388                        tracing::warn!(task_id, err = %e2, "failed to write delegation_parse_failed last_error to store");
1389                    }
1390                }
1391            }
1392        }
1393
1394        // Post result to GitHub
1395        let summary = self.get_field(task_id, "summary").await.unwrap_or_default();
1396        let last_error = self
1397            .get_field(task_id, "last_error")
1398            .await
1399            .unwrap_or_default();
1400
1401        // Determine weight signal based on outcome.
1402        // Any reroute ("new"/"routed") should avoid review-gate and go back through
1403        // normal dispatch, regardless of why it was rerouted.
1404        let is_rerouted = status == "new" || status == "routed";
1405        if is_rerouted {
1406            // Check if silence detection (tick phase1b) already re-routed this task while
1407            // the session was running. Silence detection sets status to Routed/New and
1408            // stores the fallback agent — we must NOT overwrite those fields here.
1409            // If the stored status is still InProgress, silence detection did not fire
1410            // and we can safely clear agent/model for normal reroute.
1411            let silence_already_reset = store::opt_store_get_task(&self.store, &self.repo, task_id)
1412                .await
1413                .is_some_and(|t| !matches!(t.status, TaskStatus::InProgress));
1414
1415            if silence_already_reset {
1416                tracing::debug!(
1417                    task_id,
1418                    "silence detection already set fallback agent — skipping agent/model clear"
1419                );
1420            } else if let Err(e) = store::store_set_result(
1421                &self.store,
1422                &self.repo,
1423                task_id,
1424                &[
1425                    ("agent", serde_json::json!("")),
1426                    ("model", serde_json::json!("")),
1427                ],
1428            )
1429            .await
1430            {
1431                tracing::warn!(task_id, err = %e, "failed to clear agent/model for reroute in store");
1432            }
1433        }
1434        // Determine weight signal based on outcome.
1435        // Reroutes should only emit RateLimited when the underlying error is genuinely
1436        // a rate limit (not silence detection, timeouts, parse errors, etc.). This prevents
1437        // false cooldown cascades where non-rate-limit failures are treated as rate limits.
1438        let is_rate_limit_error = classify_run_error_type(&last_error) == "rate_limit";
1439        let weight_signal = if is_rerouted {
1440            if is_rate_limit_error {
1441                WeightSignal::RateLimited {
1442                    agent: agent_name.clone(),
1443                }
1444            } else {
1445                // Non-rate-limit reroutes (silence detection, timeout, parse error): signal
1446                // the engine to keep the task in "new" for re-routing. Using Rerouted (not
1447                // None) prevents tick from converting the silence-reset back to needs_review,
1448                // which would trigger the no-code review shortcut and falsely mark it done.
1449                WeightSignal::Rerouted
1450            }
1451        } else if status == "needs_review" {
1452            // Rate limit errors that escalate to needs_review must NOT reset backoff
1453            // counters (issue #2153).  These errors indicate the agent is genuinely
1454            // unavailable — resetting would wipe the cooldown and cause the next dispatch
1455            // to retry the same rate-limited agent immediately.
1456            //
1457            // Detection: classify_run_error_type() confirms "rate limit" in last_error.
1458            // Reuse the same classifier as `is_rate_limit_error` above rather than a raw
1459            // substring check — billing-cycle exhaustion messages (e.g. Kimi's "usage
1460            // limit for this billing cycle") also contain "usage limit"/"rate limit", so
1461            // a bare substring check would record a rate-limit event into the router's
1462            // health signal for a condition that isn't a genuine rate limit (issue #3529).
1463            let has_rate_limit_error = classify_run_error_type(&last_error) == "rate_limit";
1464            if has_rate_limit_error {
1465                // Record in the metrics store so the router's health check detects it.
1466                if let Some(ref store) = self.store
1467                    && let Err(e) = store
1468                        .record_rate_limit(&agent_name, "rate", Some(task_id))
1469                        .await
1470                {
1471                    tracing::warn!(task_id, agent = %agent_name, err = %e, "failed to persist rate_limit event — router health check may miss this signal");
1472                }
1473                // Also record via the cooldown system so model_for_complexity() skips
1474                // this model.  Use the stored last_error text as the message since
1475                // classify_run_error_type() already confirmed it contains "rate limit".
1476                cooldown::record_rate_limit(&agent_name, model, &last_error).await;
1477                WeightSignal::RateLimited {
1478                    agent: agent_name.clone(),
1479                }
1480            } else {
1481                // Non-rate-limit "needs_review" (e.g. agent successfully completed but
1482                // review agent is needed) — reset backoff so the next failure starts fresh.
1483                cooldown::record_agent_success(&agent_name, model.unwrap_or("")).await;
1484                WeightSignal::Success {
1485                    agent: agent_name.clone(),
1486                }
1487            }
1488        } else if status == "done" || status == "in_progress" || status == "in_review" {
1489            // Reset exponential backoff counters so the next failure starts fresh.
1490            cooldown::record_agent_success(&agent_name, model.unwrap_or("")).await;
1491            WeightSignal::Success {
1492                agent: agent_name.clone(),
1493            }
1494        } else if status == "blocked" {
1495            WeightSignal::Blocked
1496        } else {
1497            WeightSignal::None
1498        };
1499
1500        // Record metrics
1501        {
1502            let attempts: u32 = self
1503                .get_field(task_id, "attempts")
1504                .await
1505                .and_then(|s| s.parse().ok())
1506                .unwrap_or(0);
1507            self.record_metrics(
1508                task_id,
1509                &agent_name,
1510                model,
1511                route_result,
1512                &started_at,
1513                attempts,
1514                &status,
1515                if last_error.is_empty() {
1516                    None
1517                } else {
1518                    Some(&last_error)
1519                },
1520            )
1521            .await;
1522        }
1523
1524        // Complete run in task_runs audit trail (include exit code from runner when available)
1525        if let Some(run_id) = run_audit_id
1526            && let Some(ref store) = self.store
1527            && let Err(e) = store
1528                .complete_run(&crate::store::CompleteRun {
1529                    run_id,
1530                    exit_code: exit_code_opt,
1531                    stdout: &run_audit.stdout,
1532                    stderr: &run_audit.stderr,
1533                    parsed: &run_audit.parsed_response,
1534                    outcome: &run_audit.outcome,
1535                    error: &run_audit.error,
1536                    tokens: RunTokenUsage {
1537                        input_tokens: run_audit.input_tokens,
1538                        output_tokens: run_audit.output_tokens,
1539                        total_cost_usd: run_audit.total_cost_usd,
1540                        duration_secs: run_audit.duration_secs,
1541                    },
1542                })
1543                .await
1544        {
1545            tracing::warn!(
1546                task_id,
1547                run_id,
1548                error = %e,
1549                "failed to record run completion in audit trail"
1550            );
1551        }
1552
1553        // If task was rerouted (status=routed or new after run), update GitHub agent label
1554        // so the router doesn't re-route back to the same failed agent.
1555        if is_rerouted && !is_internal_id(task_id) {
1556            let new_agent = self.get_field(task_id, "agent").await.unwrap_or_default();
1557            if !new_agent.is_empty() && new_agent != agent_name {
1558                update_agent_label_after_reroute(
1559                    &self.repo,
1560                    task,
1561                    backend,
1562                    &agent_name,
1563                    &new_agent,
1564                )
1565                .await;
1566            }
1567        }
1568
1569        // Internal tasks: skip GitHub status/comment updates — dispatch phase handles status.
1570        if is_internal_id(task_id) {
1571            return Ok(weight_signal);
1572        }
1573
1574        // Update GitHub status
1575        let new_status = match status.as_str() {
1576            "done" => Status::Done,
1577            "in_progress" => Status::InProgress,
1578            "in_review" => Status::InReview,
1579            "blocked" => Status::Blocked,
1580            "routed" => Status::Routed, // Rerouted (agent/model already set)
1581            "new" => Status::New,       // Legacy reroute path
1582            _ => Status::NeedsReview,
1583        };
1584        // Store-first: update SQLite (must succeed), then mirror to backend.
1585        if let Some(ref store) = self.store
1586            && let Ok(Some(store_id)) = store.resolve_task_id(&self.repo, &task.id.0).await
1587        {
1588            store
1589                .update_status(store_id, tasks::status_to_task_status(new_status))
1590                .await
1591                .context("store-first status update failed in runner")?;
1592        }
1593        if let Err(e) = backend.update_status(&task.id, new_status).await {
1594            tracing::warn!(
1595                task_id,
1596                ?new_status,
1597                err = %e,
1598                "failed to mirror runner status to backend — store is authoritative"
1599            );
1600        }
1601
1602        let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
1603        let mut comment = if !summary.is_empty() {
1604            format!("[{now}] {status}: {summary}")
1605        } else if !last_error.is_empty() {
1606            format!("[{now}] {status}: {last_error}")
1607        } else {
1608            format!("[{now}] {status}")
1609        };
1610
1611        // Append visible attribution footer (matches PR body style)
1612        let footer = engine::attribution_footer("Created", &agent_name, model);
1613        comment.push_str(&footer);
1614
1615        backend.post_comment(&task.id, &comment).await?;
1616
1617        Ok(weight_signal)
1618    }
1619
1620    /// Process delegations from an agent response.
1621    ///
1622    /// Creates child GitHub issues for each delegation and marks the parent
1623    /// as blocked. The engine's Phase 4 unblock mechanism will re-activate
1624    /// the parent when all children are done.
1625    async fn process_delegations(
1626        &self,
1627        parent_task: &ExternalTask,
1628        delegations: &[Delegation],
1629        backend: &Arc<dyn ExternalBackend>,
1630    ) -> anyhow::Result<()> {
1631        let parent_id = &parent_task.id;
1632
1633        // Fetch existing open tasks once for dedup — prevents creating duplicate
1634        // GitHub issues when the same delegations are produced on repeated runs.
1635        let existing_titles: HashSet<String> = match backend.list_all_tasks().await {
1636            Ok(tasks) => tasks.into_iter().map(|t| t.title).collect(),
1637            Err(e) => {
1638                tracing::warn!(err = %e, "failed to fetch tasks for dedup — will create all");
1639                HashSet::new()
1640            }
1641        };
1642
1643        for delegation in delegations {
1644            // Skip if an issue with the same title already exists
1645            if existing_titles.contains(&delegation.title) {
1646                tracing::info!(
1647                    parent = parent_id.0,
1648                    title = delegation.title,
1649                    "skipping delegation — issue with same title already exists"
1650                );
1651                continue;
1652            }
1653
1654            // Build labels: status:new + any labels from the delegation
1655            let mut labels = delegation.labels.clone();
1656            labels.push("status:new".to_string());
1657
1658            // Build child body with delegation reference
1659            let child_body = format!(
1660                "{}\n\n---\n_Delegated from #{}_{}",
1661                delegation.body,
1662                parent_id.0,
1663                engine::orch_footer()
1664            );
1665
1666            match backend
1667                .create_sub_task(parent_id, &delegation.title, &child_body, &labels)
1668                .await
1669            {
1670                Ok(child_id) => {
1671                    tracing::info!(
1672                        parent = parent_id.0,
1673                        child = child_id.0,
1674                        title = delegation.title,
1675                        "created delegated subtask"
1676                    );
1677                }
1678                Err(e) => {
1679                    tracing::error!(
1680                        parent = parent_id.0,
1681                        title = delegation.title,
1682                        err = %e,
1683                        "failed to create delegated subtask"
1684                    );
1685                }
1686            }
1687        }
1688
1689        // Mark parent as blocked — store-first, then mirror to backend.
1690        if let Some(ref store) = self.store
1691            && let Ok(Some(store_id)) = store.resolve_task_id(&self.repo, &parent_id.0).await
1692        {
1693            store
1694                .update_status(store_id, TaskStatus::Blocked)
1695                .await
1696                .context("store-first: failed to block parent in store")?;
1697        }
1698        if let Err(e) = backend.update_status(parent_id, Status::Blocked).await {
1699            tracing::warn!(
1700                parent = parent_id.0,
1701                err = %e,
1702                "failed to mirror blocked status to backend — store is authoritative"
1703            );
1704        }
1705
1706        // Post summary comment on parent
1707        let summary = delegations
1708            .iter()
1709            .enumerate()
1710            .map(|(i, d)| format!("{}. {}", i + 1, d.title))
1711            .collect::<Vec<_>>()
1712            .join("\n");
1713
1714        backend
1715            .post_comment(
1716                parent_id,
1717                &format!(
1718                    "Delegated {} subtask(s):\n\n{}\n\nParent task is blocked until all subtasks complete.{}",
1719                    delegations.len(),
1720                    summary,
1721                    engine::orch_footer()
1722                ),
1723            )
1724            .await?;
1725
1726        Ok(())
1727    }
1728
1729    /// Resolve the project directory for this repo.
1730    async fn resolve_project_dir(&self) -> anyhow::Result<PathBuf> {
1731        // Explicit env var always wins
1732        if let Ok(dir) = env::var("PROJECT_DIR")
1733            && !dir.is_empty()
1734        {
1735            return Ok(PathBuf::from(dir));
1736        }
1737
1738        // Check projects list in global config — find path for this repo
1739        if let Ok(paths) = config::get_project_paths() {
1740            for path_str in &paths {
1741                let path = PathBuf::from(path_str);
1742                // Check if this project's .orch.yml has matching repo
1743                if let Ok(repo) = config::get_repo_for_project(&path) {
1744                    // Use tokio::fs::metadata for async-safe existence check
1745                    let path_for_check = path.clone();
1746                    if repo == self.repo && fs::metadata(&path_for_check).await.is_ok() {
1747                        return Ok(path);
1748                    }
1749                }
1750            }
1751        }
1752
1753        // Legacy: check config project_dir
1754        if let Ok(dir) = config::get("project_dir")
1755            && !dir.is_empty()
1756        {
1757            let path = PathBuf::from(&dir);
1758            let path_for_check = path.clone();
1759            if fs::metadata(&path_for_check).await.is_ok() {
1760                return Ok(path);
1761            }
1762        }
1763
1764        // Check for bare clone
1765        let parts: Vec<&str> = self.repo.split('/').collect();
1766        if parts.len() == 2 {
1767            let bare = self
1768                .orch_home
1769                .join("projects")
1770                .join(parts[0])
1771                .join(format!("{}.git", parts[1]));
1772            let bare_for_check = bare.clone();
1773            if task::spawn_blocking(move || bare_for_check.exists())
1774                .await
1775                .unwrap_or(false)
1776            {
1777                return Ok(bare);
1778            }
1779        }
1780
1781        // No resolvable project directory: not in active config, no legacy
1782        // project_dir, no bare clone. Falling back to the service's own CWD
1783        // here would silently point worktree creation at an unrelated
1784        // directory (see issue #3416) — surface a clear error instead.
1785        anyhow::bail!(
1786            "no resolvable project directory for repo {} — not in active config, no bare clone at {}, no legacy project_dir set",
1787            self.repo,
1788            self.orch_home
1789                .join("projects")
1790                .join(self.repo.split('/').next().unwrap_or_default())
1791                .join(format!(
1792                    "{}.git",
1793                    self.repo.split('/').nth(1).unwrap_or_default()
1794                ))
1795                .display()
1796        )
1797    }
1798}
1799
1800/// Update the GitHub agent label on an external task when it is rerouted to a new agent.
1801///
1802/// Removes the old `agent:<old_agent>` label, ensures the new `agent:<new_agent>` label exists
1803/// in the repo, then applies it. Returns `true` only when both `ensure_label` (repo label creation)
1804/// and `set_labels` (issue label assignment) succeed. `remove_label` failures are non-fatal.
1805async fn update_agent_label_after_reroute(
1806    #[cfg_attr(
1807        test,
1808        expect(unused_variables, reason = "only the label call outside tests uses it")
1809    )]
1810    repo: &str,
1811    task: &ExternalTask,
1812    backend: &Arc<dyn ExternalBackend>,
1813    old_agent: &str,
1814    new_agent: &str,
1815) -> bool {
1816    let old_label = format!("agent:{old_agent}");
1817    if let Err(e) = backend.remove_label(&task.id, &old_label).await {
1818        tracing::warn!(
1819            task_id = ?task.id,
1820            label = old_label,
1821            error = %e,
1822            "failed to remove old agent label during re-route"
1823        );
1824    }
1825    let new_label = format!("agent:{new_agent}");
1826
1827    // ensure_label creates the repo-level label if missing (best-effort).
1828    // Skipped under #[cfg(test)] to avoid real GitHub API calls.
1829    #[cfg(not(test))]
1830    {
1831        match GhHttp::new() {
1832            Ok(gh) => {
1833                if gh
1834                    .ensure_label(
1835                        repo,
1836                        &new_label,
1837                        http::status_label_color(&new_label),
1838                        &format!("Agent: {new_agent}"),
1839                    )
1840                    .await
1841                    .is_err()
1842                {
1843                    tracing::warn!(
1844                        task_id = ?task.id,
1845                        label = %new_label,
1846                        "ensure_label failed during agent label update after failover"
1847                    );
1848                }
1849            }
1850            Err(e) => {
1851                tracing::warn!(
1852                    task_id = ?task.id,
1853                    err = %e,
1854                    "GhHttp::new() failed — skipping ensure_label"
1855                );
1856            }
1857        }
1858    }
1859
1860    if let Err(e) = backend
1861        .set_labels(&task.id, slice::from_ref(&new_label))
1862        .await
1863    {
1864        tracing::warn!(
1865            task_id = ?task.id,
1866            label = %new_label,
1867            error = %e,
1868            "set_labels failed during agent label update after failover"
1869        );
1870        return false;
1871    }
1872    true
1873}
1874
1875#[cfg(test)]
1876mod tests {
1877    use super::*;
1878    use crate::backends::{ExternalId, ExternalTask, Mention, Status};
1879    use crate::engine::runner::agents::patterns::safe_tail as safe_utf8_tail;
1880    use crate::parser::AgentResponse;
1881    use crate::store::{NewTask, TaskStore};
1882    use async_trait::async_trait;
1883    use chrono::Utc;
1884    use std::sync::{Arc, Mutex};
1885    use tempfile::TempDir;
1886    use tokio::process::Command;
1887
1888    static ENV_LOCK: Mutex<()> = Mutex::new(());
1889
1890    fn ok_parse_result(status: &str) -> agents::ParsedResponse {
1891        agents::ParsedResponse {
1892            response: AgentResponse {
1893                status: status.to_string(),
1894                summary: String::new(),
1895                accomplished: vec![],
1896                remaining: vec![],
1897                files: vec![],
1898                error: None,
1899                input_tokens: None,
1900                output_tokens: None,
1901                learnings: vec![],
1902                delegations: vec![],
1903            },
1904            input_tokens: None,
1905            output_tokens: None,
1906            duration_ms: None,
1907        }
1908    }
1909
1910    // ── safe_utf8_tail ───────────────────────────────────────────────────────
1911
1912    #[test]
1913    fn safe_utf8_tail_short_string_returned_as_is() {
1914        assert_eq!(safe_utf8_tail("hello", 100), "hello");
1915        assert_eq!(safe_utf8_tail("", 10), "");
1916    }
1917
1918    #[test]
1919    fn safe_utf8_tail_truncates_ascii() {
1920        let s = "abcdefghij"; // 10 bytes
1921        assert_eq!(safe_utf8_tail(s, 5), "fghij");
1922        assert_eq!(safe_utf8_tail(s, 10), "abcdefghij");
1923    }
1924
1925    #[test]
1926    fn safe_utf8_tail_handles_multibyte_boundary() {
1927        // "日本語" is 3 chars × 3 bytes each = 9 bytes total
1928        let s = "日本語";
1929        assert_eq!(s.len(), 9);
1930        // Cutting at 8 bytes lands in the middle of "語" (3 bytes at 6..9).
1931        // safe_utf8_tail should walk forward to the next boundary at 9 (end) rather
1932        // than panicking on an invalid slice.
1933        let tail = safe_utf8_tail(s, 8);
1934        assert!(
1935            s.ends_with(tail),
1936            "tail must be a valid suffix of the original"
1937        );
1938        assert!(
1939            str::from_utf8(tail.as_bytes()).is_ok(),
1940            "tail must be valid UTF-8"
1941        );
1942    }
1943
1944    #[test]
1945    fn safe_utf8_tail_handles_mixed_ascii_and_multibyte() {
1946        let s = "hello日本語world"; // ascii + CJK + ascii
1947        for max_bytes in [1, 3, 5, 7, 11, 13, 20, 100] {
1948            let tail = safe_utf8_tail(s, max_bytes);
1949            assert!(
1950                str::from_utf8(tail.as_bytes()).is_ok(),
1951                "max_bytes={max_bytes}: tail must be valid UTF-8"
1952            );
1953            assert!(
1954                s.ends_with(tail),
1955                "max_bytes={max_bytes}: tail must be a suffix of original"
1956            );
1957        }
1958    }
1959
1960    #[test]
1961    fn safe_utf8_tail_exact_boundary() {
1962        let s = "abcdef"; // 6 bytes
1963        assert_eq!(safe_utf8_tail(s, 6), "abcdef"); // exactly fits
1964        assert_eq!(safe_utf8_tail(s, 0), ""); // empty tail
1965    }
1966
1967    #[test]
1968    fn parse_success_output_synthesizes_done_from_plain_text() {
1969        let runner = agents::get_runner("claude");
1970        let parsed = parse_success_output(
1971            "123",
1972            "claude",
1973            &*runner,
1974            "No open positions, no trade executions, no conditions change.",
1975        )
1976        .unwrap();
1977
1978        assert_eq!(parsed.response.status, "done");
1979        assert_eq!(
1980            parsed.response.summary,
1981            "No open positions, no trade executions, no conditions change."
1982        );
1983    }
1984
1985    #[test]
1986    fn parse_success_output_synthesizes_needs_review_from_error_text() {
1987        let runner = agents::get_runner("claude");
1988        let parsed =
1989            parse_success_output("123", "claude", &*runner, "Failed to update branch").unwrap();
1990
1991        assert_eq!(parsed.response.status, "needs_review");
1992        assert_eq!(
1993            parsed.response.error.as_deref(),
1994            Some("agent returned plain text instead of JSON")
1995        );
1996    }
1997
1998    #[test]
1999    fn parse_success_output_keeps_empty_text_invalid() {
2000        let runner = agents::get_runner("claude");
2001        let err = parse_success_output("123", "claude", &*runner, "").unwrap_err();
2002
2003        assert!(matches!(err, agents::AgentError::InvalidResponse { .. }));
2004    }
2005
2006    #[test]
2007    fn parse_success_output_preserves_tokens_from_ndjson() {
2008        // Simulates NDJSON output from --output-format stream-json where
2009        // the result event contains token usage. parse_response fails on
2010        // NDJSON (expects single JSON blob), but find_agent_result extracts
2011        // tokens from the NDJSON envelope so cost tracking is preserved.
2012        let ndjson = r#"{"type":"system","subtype":"init","session_id":"abc"}
2013{"type":"assistant","message":{"content":[{"type":"text","text":"Done."}],"usage":{"input_tokens":100,"output_tokens":50}}}
2014{"type":"result","subtype":"success","is_error":false,"duration_ms":5000,"result":"All changes committed successfully","usage":{"input_tokens":1234,"output_tokens":567}}"#;
2015        let runner = agents::get_runner("claude");
2016        let parsed = parse_success_output("456", "claude", &*runner, ndjson).unwrap();
2017
2018        assert_eq!(parsed.response.status, "done");
2019        assert_eq!(parsed.input_tokens, Some(1234));
2020        assert_eq!(parsed.output_tokens, Some(567));
2021        assert_eq!(parsed.duration_ms, Some(5000));
2022    }
2023
2024    #[test]
2025    fn parse_success_output_preserves_tokens_from_minimax_ndjson() {
2026        // MiniMax/Kimi use assistant event fallback when there's no result event.
2027        let ndjson = r#"{"type":"system","subtype":"init"}
2028{"type":"assistant","message":{"content":[{"type":"text","text":"No open positions."}],"usage":{"input_tokens":2000,"output_tokens":300}}}"#;
2029        let runner = agents::get_runner("minimax");
2030        let parsed = parse_success_output("789", "minimax", &*runner, ndjson).unwrap();
2031
2032        assert_eq!(parsed.response.status, "done");
2033        assert_eq!(parsed.input_tokens, Some(2000));
2034        assert_eq!(parsed.output_tokens, Some(300));
2035    }
2036
2037    #[test]
2038    fn classify_run_error_type_recognizes_specific_patterns() {
2039        let cases = [
2040            ("push failed: remote rejected", "push_failed"),
2041            ("No commits between main and branch", "no_commits"),
2042            ("create PR failed: 422", "pr_failed"),
2043            ("invalid response from agent", "parse_error"),
2044            ("parse error: missing json", "parse_error"),
2045            ("exceeded max attempts while rerouting", "max_attempts"),
2046            ("unit test failed", "ci_failure"),
2047            ("build failed: type mismatch", "ci_failure"),
2048            ("lint failed on warning", "ci_failure"),
2049            ("3 tests failed", "ci_failure"),
2050            ("test suite failed: 3 tests failed", "ci_failure"),
2051            (
2052                "claude interrupted: exited without a result envelope",
2053                "interrupted",
2054            ),
2055        ];
2056
2057        for (input, expected) in cases {
2058            assert_eq!(classify_run_error_type(input), expected, "input={input}");
2059        }
2060    }
2061
2062    #[test]
2063    fn classify_run_error_type_falls_back_for_unknown_errors() {
2064        assert_eq!(classify_run_error_type("something unexpected"), "failed");
2065    }
2066
2067    #[test]
2068    fn classify_run_error_type_connection_error_mentioning_pull_request_is_not_pr_failed() {
2069        // A generic GitHub API or connection error whose message happens to contain the
2070        // substring "pull request" must NOT be classified as pr_failed.
2071        assert_eq!(
2072            classify_run_error_type("failed to fetch pull request details from GitHub"),
2073            "failed"
2074        );
2075        assert_eq!(
2076            classify_run_error_type("connection reset while reading pull request data"),
2077            "failed"
2078        );
2079    }
2080
2081    #[test]
2082    fn classify_run_error_type_url_with_test_substring_is_not_ci_failure() {
2083        // A connection error whose URL path contains "/test-org/" must NOT be ci_failure.
2084        assert_eq!(
2085            classify_run_error_type(
2086                "failed to connect to api.github.com/repos/test-org/repo/pulls/123"
2087            ),
2088            "failed"
2089        );
2090        // Bare "test" word in an unrelated error must not trigger ci_failure.
2091        assert_eq!(
2092            classify_run_error_type("context deadline exceeded testing connection"),
2093            "failed"
2094        );
2095    }
2096
2097    #[test]
2098    fn classify_run_error_type_usage_without_limit_is_not_rate_limit() {
2099        // "usage" alone (e.g. token usage reporting) must NOT be rate_limit.
2100        assert_eq!(
2101            classify_run_error_type("token usage: 1245 tokens"),
2102            "failed"
2103        );
2104        assert_eq!(
2105            classify_run_error_type("incorrect usage of git command"),
2106            "failed"
2107        );
2108        // Only "usage limit" should trigger rate_limit.
2109        assert_eq!(
2110            classify_run_error_type("exceeded usage limit for this billing period"),
2111            "rate_limit"
2112        );
2113    }
2114
2115    #[test]
2116    fn classify_run_error_type_billing_cycle_is_not_auth_error() {
2117        // "billing cycle" quota-exhaustion messages must be classified as billing_cycle_exhausted,
2118        // not auth_error — aligning with cooldown.rs CreditExhaustionReason::BillingCycleExhausted.
2119        assert_eq!(
2120            classify_run_error_type(
2121                "You've reached your usage limit for this billing cycle. \
2122                 Your quota will be refreshed in the next cycle."
2123            ),
2124            "billing_cycle_exhausted"
2125        );
2126        assert_eq!(
2127            classify_run_error_type("quota exhausted for billing cycle"),
2128            "billing_cycle_exhausted"
2129        );
2130        // Plain "billing" without "cycle" that used to map to auth_error now falls through to "failed".
2131        assert_eq!(
2132            classify_run_error_type("billing account suspended"),
2133            "failed"
2134        );
2135    }
2136
2137    #[test]
2138    fn classify_run_error_type_monthly_quota_is_billing_cycle() {
2139        // Regression test for issue #3302: GitHub Copilot "You have exceeded your
2140        // monthly quota" must map to billing_cycle_exhausted, not "failed".
2141        assert_eq!(
2142            classify_run_error_type(
2143                "opencode failed: agent failed: You have exceeded your monthly quota"
2144            ),
2145            "billing_cycle_exhausted"
2146        );
2147        assert_eq!(
2148            classify_run_error_type("You have exceeded your monthly quota"),
2149            "billing_cycle_exhausted"
2150        );
2151    }
2152
2153    #[test]
2154    fn classify_run_outcome_routed_is_success() {
2155        // "routed" is a canonical non-success status (task will be re-dispatched).
2156        // It should be classified as "success" so the audit trail distinguishes it
2157        // from genuine failures (#2801).
2158        let parse_result = Ok(ok_parse_result("routed"));
2159        assert_eq!(
2160            classify_run_outcome("routed", &parse_result, false),
2161            "success"
2162        );
2163    }
2164
2165    #[test]
2166    fn classify_run_outcome_blocked_is_blocked() {
2167        let parse_result = Ok(ok_parse_result("blocked"));
2168        assert_eq!(
2169            classify_run_outcome("blocked", &parse_result, false),
2170            "blocked"
2171        );
2172    }
2173
2174    #[test]
2175    fn classify_run_outcome_non_canonical_is_failed() {
2176        // Non-canonical statuses should be classified as "failed" in the audit.
2177        // The explicit error message is provided by build_run_audit.
2178        let parse_result = Ok(ok_parse_result("fix_deployed"));
2179        assert_eq!(
2180            classify_run_outcome("fix_deployed", &parse_result, false),
2181            "failed"
2182        );
2183    }
2184
2185    #[test]
2186    fn classify_run_outcome_kimi_billing_cycle_is_not_rate_limit() {
2187        // Regression test for issue #3529: Kimi's exact billing-cycle exhaustion
2188        // message was stored as outcome="rate_limit" because the generic "usage
2189        // limit" pattern in detect_rate_limit() matched before credit-exhaustion
2190        // detection ever ran. The outcome must reflect the actual failure mode.
2191        let parse_result: Result<agents::ParsedResponse, agents::AgentError> =
2192            Err(agents::AgentError::RateLimit {
2193                message: "Failed to authenticate. API Error: 403 You've reached your usage \
2194                          limit for this billing cycle. Your quota will be refreshed soon."
2195                    .to_string(),
2196            });
2197        assert_eq!(
2198            classify_run_outcome("needs_review", &parse_result, false),
2199            "billing_cycle_exhausted"
2200        );
2201    }
2202
2203    #[test]
2204    fn classify_run_outcome_generic_rate_limit_is_unaffected() {
2205        // A genuine rate limit (no billing-cycle/credit-exhaustion markers) must
2206        // still classify as "rate_limit".
2207        let parse_result: Result<agents::ParsedResponse, agents::AgentError> =
2208            Err(agents::AgentError::RateLimit {
2209                message: "429 Too Many Requests".to_string(),
2210            });
2211        assert_eq!(
2212            classify_run_outcome("needs_review", &parse_result, false),
2213            "rate_limit"
2214        );
2215    }
2216
2217    #[test]
2218    fn classify_run_outcome_auth_billing_cycle_is_not_failed() {
2219        // Auth-classified billing-cycle exhaustion (e.g. "billing cycle exhausted"
2220        // matches detect_auth_error's pattern list) must also be distinguished from
2221        // a generic auth failure.
2222        let parse_result: Result<agents::ParsedResponse, agents::AgentError> =
2223            Err(agents::AgentError::Auth {
2224                message: "billing cycle exhausted for this account".to_string(),
2225            });
2226        assert_eq!(
2227            classify_run_outcome("needs_review", &parse_result, false),
2228            "billing_cycle_exhausted"
2229        );
2230    }
2231
2232    #[test]
2233    fn classify_run_outcome_generic_auth_error_is_unaffected() {
2234        let parse_result: Result<agents::ParsedResponse, agents::AgentError> =
2235            Err(agents::AgentError::Auth {
2236                message: "401 Unauthorized".to_string(),
2237            });
2238        assert_eq!(
2239            classify_run_outcome("needs_review", &parse_result, false),
2240            "failed"
2241        );
2242    }
2243
2244    #[test]
2245    fn classify_run_outcome_interrupted() {
2246        let parse_result: Result<agents::ParsedResponse, agents::AgentError> =
2247            Err(agents::AgentError::Interrupted {
2248                message: "exited without a result envelope".to_string(),
2249            });
2250        assert_eq!(
2251            classify_run_outcome("new", &parse_result, false),
2252            "interrupted"
2253        );
2254    }
2255
2256    #[tokio::test]
2257    async fn build_run_audit_success_ignores_stale_last_error() {
2258        let store = Arc::new(TaskStore::open_memory().await.unwrap());
2259        let task_id = store
2260            .create(&NewTask {
2261                repo: "owner/repo".to_string(),
2262                origin: "internal".to_string(),
2263                title: "Test".to_string(),
2264                ..Default::default()
2265            })
2266            .await
2267            .unwrap();
2268        store
2269            .set_fields(
2270                task_id,
2271                &[("last_error", serde_json::json!("no error info available"))],
2272            )
2273            .await
2274            .unwrap();
2275
2276        let runner = TaskRunner::new("owner/repo".to_string()).with_store(store);
2277        let parse_result = Ok(ok_parse_result("done"));
2278        let started_at = Utc::now();
2279        let audit = runner
2280            .build_run_audit(RunAuditInput {
2281                task_id: &task_id.to_string(),
2282                status: "done",
2283                parse_result: &parse_result,
2284                raw_stdout: "",
2285                raw_stderr: "",
2286                started_at: &started_at,
2287                error_override: None,
2288                elapsed_secs: Some(1),
2289                push_failed: false,
2290            })
2291            .await;
2292
2293        assert_eq!(audit.outcome, "success");
2294        assert_eq!(audit.error, "");
2295    }
2296
2297    #[tokio::test]
2298    async fn build_run_audit_ok_parse_prefers_parsed_error_over_stale_last_error() {
2299        // needs_review classifies as success and wipes the error. The #3851
2300        // misattribution is an Ok parse whose outcome is failed or blocked
2301        let cases = [("needs_action", "failed"), ("blocked", "blocked")];
2302        let parsed_error = "passbox Touch ID prompt timed out for cf/read-token";
2303        let stale = "minimax auth error: API Error: 402 insufficient balance (1008), clearing agent/model for re-route";
2304        for (status, expected_outcome) in cases {
2305            let store = Arc::new(TaskStore::open_memory().await.unwrap());
2306            let task_id = store
2307                .create(&NewTask {
2308                    repo: "owner/repo".to_string(),
2309                    origin: "internal".to_string(),
2310                    title: "Test".to_string(),
2311                    ..Default::default()
2312                })
2313                .await
2314                .unwrap();
2315            store
2316                .set_fields(task_id, &[("last_error", serde_json::json!(stale))])
2317                .await
2318                .unwrap();
2319
2320            let runner = TaskRunner::new("owner/repo".to_string()).with_store(store);
2321            let parse_result = Ok(agents::ParsedResponse {
2322                response: AgentResponse {
2323                    status: status.to_string(),
2324                    summary: "cache_stats.py did not run".to_string(),
2325                    accomplished: vec![],
2326                    remaining: vec![],
2327                    files: vec![],
2328                    error: Some(parsed_error.to_string()),
2329                    input_tokens: None,
2330                    output_tokens: None,
2331                    learnings: vec![],
2332                    delegations: vec![],
2333                },
2334                input_tokens: None,
2335                output_tokens: None,
2336                duration_ms: None,
2337            });
2338            let started_at = Utc::now();
2339            let audit = runner
2340                .build_run_audit(RunAuditInput {
2341                    task_id: &task_id.to_string(),
2342                    status,
2343                    parse_result: &parse_result,
2344                    raw_stdout: "",
2345                    raw_stderr: "",
2346                    started_at: &started_at,
2347                    error_override: None,
2348                    elapsed_secs: Some(261),
2349                    push_failed: false,
2350                })
2351                .await;
2352
2353            assert_eq!(audit.outcome, expected_outcome, "status={status}");
2354            assert_eq!(audit.error, parsed_error, "status={status}");
2355        }
2356    }
2357
2358    #[tokio::test]
2359    async fn build_run_audit_non_success_uses_fallback_error_when_empty() {
2360        let runner = TaskRunner::new("owner/repo".to_string());
2361        let parse_result = Ok(ok_parse_result("new"));
2362        let started_at = Utc::now();
2363        let audit = runner
2364            .build_run_audit(RunAuditInput {
2365                task_id: "1",
2366                status: "new",
2367                parse_result: &parse_result,
2368                raw_stdout: "",
2369                raw_stderr: "",
2370                started_at: &started_at,
2371                error_override: None,
2372                elapsed_secs: Some(1),
2373                push_failed: false,
2374            })
2375            .await;
2376
2377        assert_eq!(audit.outcome, "failed");
2378        // "new" is a canonical non-success status — no error message needed.
2379        assert_eq!(audit.error, "");
2380    }
2381
2382    #[tokio::test]
2383    async fn build_run_audit_non_canonical_status_reports_explicit_error() {
2384        // Non-canonical statuses (not in the allowlist) with no parse error
2385        // should get an explicit "unrecognized status: X" message instead of
2386        // the opaque "no error info available" placeholder (#2801).
2387        let runner = TaskRunner::new("owner/repo".to_string());
2388        let parse_result = Ok(ok_parse_result("fix_deployed"));
2389        let started_at = Utc::now();
2390        let audit = runner
2391            .build_run_audit(RunAuditInput {
2392                task_id: "1",
2393                status: "fix_deployed",
2394                parse_result: &parse_result,
2395                raw_stdout: "",
2396                raw_stderr: "",
2397                started_at: &started_at,
2398                error_override: None,
2399                elapsed_secs: Some(1),
2400                push_failed: false,
2401            })
2402            .await;
2403
2404        assert_eq!(audit.outcome, "failed");
2405        assert_eq!(audit.error, "unrecognized status: fix_deployed");
2406    }
2407
2408    #[tokio::test]
2409    async fn build_run_audit_canonical_non_success_status_no_error_message() {
2410        // Canonical non-success statuses (e.g. "routed") should not produce
2411        // an error message — they're expected outcomes, not failures.
2412        for status in &["routed"] {
2413            let runner = TaskRunner::new("owner/repo".to_string());
2414            let parse_result = Ok(ok_parse_result(status));
2415            let started_at = Utc::now();
2416            let audit = runner
2417                .build_run_audit(RunAuditInput {
2418                    task_id: "1",
2419                    status,
2420                    parse_result: &parse_result,
2421                    raw_stdout: "",
2422                    raw_stderr: "",
2423                    started_at: &started_at,
2424                    error_override: None,
2425                    elapsed_secs: Some(1),
2426                    push_failed: false,
2427                })
2428                .await;
2429
2430            assert_eq!(audit.outcome, "success", "status={status}");
2431            assert_eq!(
2432                audit.error, "",
2433                "status={status}: no error for canonical status"
2434            );
2435        }
2436    }
2437
2438    #[tokio::test]
2439    async fn build_run_audit_blocked_uses_summary_when_error_missing() {
2440        let runner = TaskRunner::new("owner/repo".to_string());
2441        let parse_result = Ok(agents::ParsedResponse {
2442            response: AgentResponse {
2443                status: "blocked".to_string(),
2444                summary: "Could not proceed due to missing credentials".to_string(),
2445                accomplished: vec![],
2446                remaining: vec![],
2447                files: vec![],
2448                error: None,
2449                input_tokens: None,
2450                output_tokens: None,
2451                learnings: vec![],
2452                delegations: vec![],
2453            },
2454            input_tokens: None,
2455            output_tokens: None,
2456            duration_ms: None,
2457        });
2458        let started_at = Utc::now();
2459        let audit = runner
2460            .build_run_audit(RunAuditInput {
2461                task_id: "1",
2462                status: "blocked",
2463                parse_result: &parse_result,
2464                raw_stdout: "",
2465                raw_stderr: "",
2466                started_at: &started_at,
2467                error_override: None,
2468                elapsed_secs: Some(1),
2469                push_failed: false,
2470            })
2471            .await;
2472
2473        assert_eq!(audit.outcome, "blocked");
2474        assert_eq!(audit.error, "Could not proceed due to missing credentials");
2475    }
2476
2477    // ── resolve_project_dir ──────────────────────────────────────────────────
2478
2479    #[tokio::test]
2480    async fn resolve_project_dir_uses_project_dir_env() {
2481        // When PROJECT_DIR env var is set, it should always win.
2482        let runner = TaskRunner::new("owner/repo".to_string());
2483
2484        // Use a temp dir so the path exists
2485        let dir = tempfile::tempdir().unwrap();
2486        let dir_str = dir.path().to_string_lossy().to_string();
2487
2488        // Temporarily set the env var — note: tests run in parallel so we use a
2489        // dedicated key that only this test touches. std::env::set_var is not
2490        // thread-safe in general, but the subsequent call is synchronous and
2491        // isolated to the temp dir created above.
2492        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
2493        unsafe { env::set_var("PROJECT_DIR", &dir_str) };
2494        let result = runner.resolve_project_dir().await;
2495        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
2496        unsafe { env::remove_var("PROJECT_DIR") };
2497
2498        assert!(
2499            result.is_ok(),
2500            "resolve_project_dir should succeed with PROJECT_DIR set"
2501        );
2502        assert_eq!(result.unwrap(), dir.path());
2503    }
2504
2505    #[tokio::test]
2506    async fn resolve_project_dir_empty_project_dir_env_falls_through() {
2507        // An empty PROJECT_DIR should be ignored and fall through to other logic.
2508        let runner = TaskRunner::new("owner/testrepo-nonexistent".to_string());
2509        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
2510        unsafe { env::set_var("PROJECT_DIR", "") };
2511        let result = runner.resolve_project_dir().await;
2512        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
2513        unsafe { env::remove_var("PROJECT_DIR") };
2514
2515        // No active config entry, no legacy project_dir, no bare clone for this
2516        // repo: should return an explicit error rather than falling back to the
2517        // service's own CWD (see issue #3416).
2518        assert!(
2519            result.is_err(),
2520            "resolve_project_dir should error when no project dir can be resolved"
2521        );
2522        let err = result.unwrap_err().to_string();
2523        assert!(
2524            err.contains("owner/testrepo-nonexistent"),
2525            "error should name the unresolvable repo: {err}"
2526        );
2527    }
2528
2529    // ── mock backend for process_delegations ─────────────────────────────────
2530
2531    struct TrackingBackend {
2532        sub_tasks_created: Arc<Mutex<Vec<(String, String)>>>, // (title, parent_id)
2533        status_updates: Arc<Mutex<Vec<(String, Status)>>>,
2534        comments: Arc<Mutex<Vec<(String, String)>>>, // (id, body)
2535    }
2536
2537    impl TrackingBackend {
2538        fn new() -> Self {
2539            Self {
2540                sub_tasks_created: Arc::new(Mutex::new(vec![])),
2541                status_updates: Arc::new(Mutex::new(vec![])),
2542                comments: Arc::new(Mutex::new(vec![])),
2543            }
2544        }
2545    }
2546
2547    #[async_trait]
2548    impl ExternalBackend for TrackingBackend {
2549        fn name(&self) -> &'static str {
2550            "tracking"
2551        }
2552        async fn create_task(
2553            &self,
2554            _t: &str,
2555            _b: &str,
2556            _l: &[String],
2557        ) -> anyhow::Result<ExternalId> {
2558            Ok(ExternalId("new".to_string()))
2559        }
2560        async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
2561            Ok(ExternalTask {
2562                id: id.clone(),
2563                title: String::new(),
2564                body: String::new(),
2565                state: "open".to_string(),
2566                labels: vec![],
2567                author: String::new(),
2568                created_at: "2026-01-01T00:00:00Z".to_string(),
2569                updated_at: "2026-01-01T00:00:00Z".to_string(),
2570                url: String::new(),
2571            })
2572        }
2573        async fn list_by_status(&self, _s: Status) -> anyhow::Result<Vec<ExternalTask>> {
2574            Ok(vec![])
2575        }
2576        async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2577            Ok(vec![])
2578        }
2579        async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
2580            self.comments
2581                .lock()
2582                .unwrap()
2583                .push((id.0.clone(), body.to_string()));
2584            Ok(())
2585        }
2586        async fn set_labels(&self, _id: &ExternalId, _l: &[String]) -> anyhow::Result<()> {
2587            Ok(())
2588        }
2589        async fn remove_label(&self, _id: &ExternalId, _l: &str) -> anyhow::Result<()> {
2590            Ok(())
2591        }
2592        async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2593            Ok(vec![])
2594        }
2595        async fn create_sub_task(
2596            &self,
2597            parent: &ExternalId,
2598            title: &str,
2599            _body: &str,
2600            _l: &[String],
2601        ) -> anyhow::Result<ExternalId> {
2602            self.sub_tasks_created
2603                .lock()
2604                .unwrap()
2605                .push((title.to_string(), parent.0.clone()));
2606            Ok(ExternalId(format!("child-{title}")))
2607        }
2608        async fn ensure_status_label(&self, _l: &str) -> anyhow::Result<()> {
2609            Ok(())
2610        }
2611        async fn has_open_issue_with_title(&self, _t: &str, _l: &str) -> anyhow::Result<bool> {
2612            Ok(false)
2613        }
2614        async fn health_check(&self) -> anyhow::Result<()> {
2615            Ok(())
2616        }
2617        async fn is_pr_merged(&self, _b: &str) -> anyhow::Result<bool> {
2618            Ok(false)
2619        }
2620        async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
2621            Ok(None)
2622        }
2623        async fn get_mentions(&self, _s: &str) -> anyhow::Result<Vec<Mention>> {
2624            Ok(vec![])
2625        }
2626        async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
2627            self.status_updates
2628                .lock()
2629                .unwrap()
2630                .push((id.0.clone(), status));
2631            Ok(())
2632        }
2633    }
2634
2635    fn make_task(id: &str) -> ExternalTask {
2636        ExternalTask {
2637            id: ExternalId(id.to_string()),
2638            title: format!("Task {id}"),
2639            body: String::new(),
2640            state: "open".to_string(),
2641            labels: vec![],
2642            author: "bot".to_string(),
2643            created_at: "2026-01-01T00:00:00Z".to_string(),
2644            updated_at: "2026-01-01T00:00:00Z".to_string(),
2645            url: String::new(),
2646        }
2647    }
2648
2649    // ── process_delegations ───────────────────────────────────────────────────
2650
2651    #[expect(
2652        clippy::await_holding_lock,
2653        reason = "the env lock must stay held across the awaited calls"
2654    )]
2655    #[tokio::test]
2656    async fn process_delegations_creates_subtasks_and_blocks_parent() {
2657        let _guard = ENV_LOCK.lock().unwrap();
2658        let temp_home = TempDir::new().unwrap();
2659        let orch_home = temp_home.path().join(".orch");
2660        fs::create_dir_all(&orch_home).await.unwrap();
2661        let old_orch_home = env::var("ORCH_HOME").ok();
2662        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
2663        unsafe { env::set_var("ORCH_HOME", &orch_home) };
2664
2665        let runner = TaskRunner::new("owner/repo".to_string());
2666        let parent = make_task("99");
2667        let backend = Arc::new(TrackingBackend::new());
2668        let backend_dyn: Arc<dyn ExternalBackend> = Arc::<TrackingBackend>::clone(&backend);
2669
2670        let delegations = vec![
2671            Delegation {
2672                title: "Subtask A".to_string(),
2673                body: "Do A".to_string(),
2674                labels: vec!["enhancement".to_string()],
2675            },
2676            Delegation {
2677                title: "Subtask B".to_string(),
2678                body: "Do B".to_string(),
2679                labels: vec![],
2680            },
2681        ];
2682
2683        runner
2684            .process_delegations(&parent, &delegations, &backend_dyn)
2685            .await
2686            .unwrap();
2687
2688        // Two sub-tasks should have been created
2689        let created = backend.sub_tasks_created.lock().unwrap();
2690        assert_eq!(created.len(), 2);
2691        assert_eq!(created[0].1, "99", "parent id should be 99");
2692        assert!(
2693            created.iter().any(|(t, _)| t == "Subtask A"),
2694            "Subtask A should be created"
2695        );
2696        assert!(
2697            created.iter().any(|(t, _)| t == "Subtask B"),
2698            "Subtask B should be created"
2699        );
2700        drop(created);
2701
2702        // Parent should be marked blocked
2703        let updates = backend.status_updates.lock().unwrap();
2704        assert!(
2705            updates
2706                .iter()
2707                .any(|(id, s)| id == "99" && *s == Status::Blocked),
2708            "parent should be blocked"
2709        );
2710        drop(updates);
2711
2712        // A comment summarising the delegations should be posted
2713        let comments = backend.comments.lock().unwrap();
2714        assert!(
2715            !comments.is_empty(),
2716            "a delegation summary comment should be posted"
2717        );
2718        let comment_body = &comments[0].1;
2719        assert!(
2720            comment_body.contains("Subtask A"),
2721            "summary should mention Subtask A"
2722        );
2723        assert!(
2724            comment_body.contains("Subtask B"),
2725            "summary should mention Subtask B"
2726        );
2727
2728        if let Some(old) = old_orch_home {
2729            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
2730            unsafe { env::set_var("ORCH_HOME", old) };
2731        } else {
2732            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
2733            unsafe { env::remove_var("ORCH_HOME") };
2734        }
2735    }
2736
2737    #[expect(
2738        clippy::await_holding_lock,
2739        reason = "the env lock must stay held across the awaited calls"
2740    )]
2741    #[tokio::test]
2742    async fn process_delegations_single_subtask() {
2743        let _guard = ENV_LOCK.lock().unwrap();
2744        let temp_home = TempDir::new().unwrap();
2745        let orch_home = temp_home.path().join(".orch");
2746        fs::create_dir_all(&orch_home).await.unwrap();
2747        let old_orch_home = env::var("ORCH_HOME").ok();
2748        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
2749        unsafe { env::set_var("ORCH_HOME", &orch_home) };
2750
2751        let runner = TaskRunner::new("owner/repo".to_string());
2752        let parent = make_task("101");
2753        let backend = Arc::new(TrackingBackend::new());
2754        let backend_dyn: Arc<dyn ExternalBackend> = Arc::<TrackingBackend>::clone(&backend);
2755
2756        let delegations = vec![Delegation {
2757            title: "Only Child".to_string(),
2758            body: "Do only child".to_string(),
2759            labels: vec!["feature".to_string()],
2760        }];
2761
2762        runner
2763            .process_delegations(&parent, &delegations, &backend_dyn)
2764            .await
2765            .unwrap();
2766
2767        let created = backend.sub_tasks_created.lock().unwrap();
2768        assert_eq!(created.len(), 1);
2769        assert_eq!(created[0].0, "Only Child");
2770        drop(created);
2771
2772        let updates = backend.status_updates.lock().unwrap();
2773        assert!(
2774            updates
2775                .iter()
2776                .any(|(id, s)| id == "101" && *s == Status::Blocked),
2777            "parent should be blocked"
2778        );
2779        drop(updates);
2780
2781        let comments = backend.comments.lock().unwrap();
2782        let body = &comments[0].1;
2783        assert!(
2784            body.contains("1 subtask"),
2785            "comment should count one subtask"
2786        );
2787
2788        if let Some(old) = old_orch_home {
2789            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
2790            unsafe { env::set_var("ORCH_HOME", old) };
2791        } else {
2792            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
2793            unsafe { env::remove_var("ORCH_HOME") };
2794        }
2795    }
2796
2797    // ── weight signal logic ───────────────────────────────────────────────────
2798
2799    /// Test helper that mirrors the weight signal logic from `run_with_context`.
2800    /// Reroutes only emit `RateLimited` when the error type is genuinely "rate_limit".
2801    fn weight_signal_for(status: &str, error_type: &str, agent: &str) -> WeightSignal {
2802        let is_rerouted = status == "new" || status == "routed";
2803        let is_rate_limit_error = error_type == "rate_limit";
2804        if is_rerouted {
2805            if is_rate_limit_error {
2806                WeightSignal::RateLimited {
2807                    agent: agent.to_string(),
2808                }
2809            } else {
2810                WeightSignal::Rerouted
2811            }
2812        } else if status == "done"
2813            || status == "needs_review"
2814            || status == "in_progress"
2815            || status == "in_review"
2816        {
2817            WeightSignal::Success {
2818                agent: agent.to_string(),
2819            }
2820        } else if status == "blocked" {
2821            WeightSignal::Blocked
2822        } else {
2823            WeightSignal::None
2824        }
2825    }
2826
2827    #[test]
2828    fn weight_signal_success_includes_needs_review() {
2829        // The normal happy-path for code tasks: agent reports done, has a PR,
2830        // so handle_success returns "needs_review". This must map to Success.
2831        let signal = weight_signal_for("needs_review", "success", "claude");
2832        assert!(
2833            matches!(signal, WeightSignal::Success { agent } if agent == "claude"),
2834            "needs_review should produce WeightSignal::Success"
2835        );
2836    }
2837
2838    #[test]
2839    fn weight_signal_success_for_all_done_statuses() {
2840        for status in ["done", "needs_review", "in_progress", "in_review"] {
2841            let signal = weight_signal_for(status, "success", "codex");
2842            assert!(
2843                matches!(signal, WeightSignal::Success { agent } if agent == "codex"),
2844                "{status} should produce WeightSignal::Success"
2845            );
2846        }
2847    }
2848
2849    #[test]
2850    fn weight_signal_rate_limited_for_reroute_with_rate_limit_error() {
2851        // Reroute due to rate limit should emit RateLimited.
2852        let signal = weight_signal_for("new", "rate_limit", "claude");
2853        assert!(matches!(signal, WeightSignal::RateLimited { agent } if agent == "claude"));
2854    }
2855
2856    #[test]
2857    fn weight_signal_rate_limited_for_routed_status_with_rate_limit_error() {
2858        let signal = weight_signal_for("routed", "rate_limit", "claude");
2859        assert!(matches!(signal, WeightSignal::RateLimited { agent } if agent == "claude"));
2860    }
2861
2862    #[test]
2863    fn weight_signal_rerouted_for_non_rate_limit_reroute() {
2864        // Reroute due to silence detection, timeout, or other non-rate-limit errors
2865        // should emit Rerouted (not RateLimited and not None).
2866        // Rerouted maps to "new" in tick so the task stays retryable and is never
2867        // converted to needs_review → done via the no-code review shortcut.
2868        for error_type in ["timeout", "silence detected", "parse_error", "failed"] {
2869            for status in ["new", "routed"] {
2870                let signal = weight_signal_for(status, error_type, "claude");
2871                assert!(
2872                    matches!(signal, WeightSignal::Rerouted),
2873                    "{status} with error_type={error_type} should produce WeightSignal::Rerouted, not RateLimited or None"
2874                );
2875            }
2876        }
2877    }
2878
2879    #[test]
2880    fn silence_reset_rerouted_never_becomes_needs_review() {
2881        // Regression test for: silence detection sets task to new, runner returns
2882        // WeightSignal::Rerouted, tick must map that to "new" — not "needs_review".
2883        // If it mapped to needs_review the no-code shortcut would close the task as
2884        // done with no successful agent run in the audit trail.
2885        let signal = weight_signal_for("new", "silence detected", "claude");
2886        assert!(
2887            matches!(signal, WeightSignal::Rerouted),
2888            "silence-reset reroute must produce Rerouted, never None/needs_review"
2889        );
2890        // Verify tick mapping: Rerouted => "new" (not needs_review or done)
2891        let display_status = match &signal {
2892            WeightSignal::Rerouted => "new",
2893            WeightSignal::None => "needs_review", // the old (buggy) path
2894            _ => "other",
2895        };
2896        assert_eq!(
2897            display_status, "new",
2898            "Rerouted must map to 'new' in tick, not 'needs_review'"
2899        );
2900    }
2901
2902    #[test]
2903    fn weight_signal_blocked_status() {
2904        let signal = weight_signal_for("blocked", "success", "claude");
2905        assert!(matches!(signal, WeightSignal::Blocked));
2906    }
2907
2908    // ── parse_success_output token extraction ────────────────────────────────
2909
2910    /// Mock runner that always returns InvalidResponse with truncated text,
2911    /// simulating what minimax/codex do when they can't parse structured JSON.
2912    struct FailingMockRunner;
2913
2914    impl agents::AgentRunner for FailingMockRunner {
2915        #[cfg(test)]
2916        fn name(&self) -> &'static str {
2917            "opencode"
2918        }
2919
2920        fn stream_events(&self, _event: &serde_json::Value) -> Vec<agents::StreamEvent> {
2921            vec![]
2922        }
2923
2924        fn build_command(
2925            &self,
2926            _model: Option<&str>,
2927            _timeout_cmd: &str,
2928            _sys_file: &str,
2929            _msg_file: &str,
2930            _permissions: &agents::PermissionRules,
2931        ) -> String {
2932            String::new()
2933        }
2934
2935        fn parse_response(&self, raw: &str) -> Result<agents::ParsedResponse, agents::AgentError> {
2936            // Simulate what minimax does: truncate to 300 chars
2937            Err(agents::AgentError::InvalidResponse {
2938                raw: raw.chars().take(30).collect(),
2939            })
2940        }
2941
2942        fn extract_text(&self, raw: &str) -> Result<String, agents::AgentError> {
2943            Err(agents::AgentError::InvalidResponse {
2944                raw: raw.to_string(),
2945            })
2946        }
2947
2948        fn classify_error(
2949            &self,
2950            exit_code: i32,
2951            _stdout: &str,
2952            _stderr: &str,
2953        ) -> agents::AgentError {
2954            agents::AgentError::Unknown {
2955                exit_code,
2956                message: "mock error".into(),
2957            }
2958        }
2959
2960        fn router_command(&self, _prompt: &str, _model: Option<&str>) -> anyhow::Result<Command> {
2961            anyhow::bail!("not implemented for mock")
2962        }
2963    }
2964
2965    #[test]
2966    fn parse_success_output_extracts_tokens_from_raw_stdout_not_error_raw() {
2967        // OpenCode NDJSON output with token metadata in step_finish
2968        let ndjson = r#"{"type":"text","timestamp":1,"part":{"type":"text","text":"Here is my response: {\"status\":\"done\",\"summary\":\"fixed\"}}"}}
2969{"type":"step_finish","timestamp":2,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":95177,"input":94920,"output":257}}}"#;
2970
2971        let runner = FailingMockRunner;
2972        let result = parse_success_output("1550", "opencode", &runner, ndjson);
2973
2974        // Should succeed (synthesized from text) with tokens preserved
2975        let parsed = result.expect("should synthesize response from text");
2976        assert_eq!(parsed.input_tokens, Some(94920));
2977        assert_eq!(parsed.output_tokens, Some(257));
2978    }
2979
2980    #[test]
2981    fn parse_success_output_no_tokens_when_ndjson_has_none() {
2982        // Plain text with an explicit completion signal but no token metadata.
2983        // Uses "No open positions" which is in the explicit_done list.
2984        let text = "No open positions, no trade executions, no conditions change.";
2985
2986        let runner = FailingMockRunner;
2987        let result = parse_success_output("1551", "opencode", &runner, text);
2988
2989        // Should synthesize a "done" response, but without tokens (no NDJSON envelope).
2990        let parsed = result.expect("should synthesize response from explicit-done text");
2991        assert_eq!(parsed.input_tokens, None);
2992        assert_eq!(parsed.output_tokens, None);
2993    }
2994
2995    #[test]
2996    fn parse_success_output_uses_agent_result_directly() {
2997        // Here we test that if find_agent_result succeeds and returns valid JSON, we use it directly
2998        // WITHOUT calling agent_runner.parse_response at all.
2999        let ndjson = r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"status\":\"done\",\"summary\":\"my summary\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}"#;
3000
3001        // FailingMockRunner always returns InvalidResponse, so if we called it, we'd synthesize "done" instead of getting "my summary".
3002        let runner = FailingMockRunner;
3003        let result = parse_success_output("999", "claude", &runner, ndjson);
3004
3005        let parsed = result.expect("should parse successfully via find_agent_result");
3006        assert_eq!(parsed.response.status, "done");
3007        assert_eq!(parsed.response.summary, "my summary");
3008    }
3009
3010    #[test]
3011    fn parse_success_output_treats_agent_result_is_error_as_authoritative() {
3012        // Here we test that if find_agent_result returns is_error=true, we return AgentFailed
3013        // instead of falling back to synthesis or agent_runner.parse_response.
3014        let ndjson = r#"{"type":"result","subtype":"success","is_error":true,"result":"I failed because of reasons"}"#;
3015
3016        let runner = FailingMockRunner;
3017        let result = parse_success_output("999", "claude", &runner, ndjson);
3018
3019        let err = result.expect_err("should return error");
3020        match err {
3021            agents::AgentError::AgentFailed { message } => {
3022                assert_eq!(message, "I failed because of reasons");
3023            }
3024            _ => panic!("expected AgentFailed, got {err:?}"),
3025        }
3026    }
3027
3028    #[test]
3029    fn parse_success_output_classifies_model_unavailable_from_is_error_result() {
3030        // Regression test for issue #3607: the is_error=true NDJSON path
3031        // (used by codex's find_codex_result, which stringifies its own
3032        // classified AgentError into result_text) must re-classify a
3033        // model-not-found message as ModelUnavailable so it gets
3034        // model-scoped cooldown treatment instead of degrading to a
3035        // generic AgentFailed, which triggers an agent-wide cooldown.
3036        let ndjson = r#"{"type":"result","subtype":"success","is_error":true,"result":"model unavailable (gpt-5.5): Reconnecting... 2/5 (unexpected status 404 Not Found: The model `gpt-5.5` does not exist or you do not have access to it.)"}"#;
3037
3038        let runner = FailingMockRunner;
3039        let result = parse_success_output("999", "claude", &runner, ndjson);
3040
3041        let err = result.expect_err("should return error");
3042        match err {
3043            agents::AgentError::ModelUnavailable { model, .. } => {
3044                assert_eq!(model, "gpt-5.5");
3045            }
3046            _ => panic!("expected ModelUnavailable, got {err:?}"),
3047        }
3048    }
3049
3050    #[test]
3051    fn nonzero_exit_with_ndjson_success_is_parsed_as_success() {
3052        // Simulate an agent that exits with non-zero code but emits NDJSON result
3053        // with is_error=false and a JSON result payload. The runner should accept
3054        // this as success because the NDJSON result is authoritative.
3055        let ndjson = r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":1234,"result":"{\"status\":\"done\",\"summary\":\"All good\"}","usage":{"input_tokens":10,"output_tokens":5}}"#;
3056
3057        // Use a runner that would normally fail parsing to ensure we're using
3058        // the find_agent_result path. FailingMockRunner.parse_response returns
3059        // InvalidResponse, but parse_success_output should short-circuit.
3060        let runner = FailingMockRunner;
3061
3062        // Construct a fake SessionOutput
3063        let session_output = session::SessionOutput {
3064            exit_code: 1,
3065            raw_stdout: ndjson.to_string(),
3066            raw_stderr: String::new(),
3067            elapsed_secs: Some(1),
3068            sandbox_error: None,
3069        };
3070
3071        // Call the helper we added via the task runner logic by reusing the
3072        // parse_success_output directly since it encapsulates the same behavior.
3073        let parsed =
3074            parse_success_output("task-ndjson", "claude", &runner, &session_output.raw_stdout)
3075                .expect("should parse NDJSON success");
3076        assert_eq!(parsed.response.status, "done");
3077        assert_eq!(parsed.response.summary, "All good");
3078        assert_eq!(parsed.input_tokens, Some(10));
3079        assert_eq!(parsed.output_tokens, Some(5));
3080    }
3081
3082    // ── agent label update after reroute ─────────────────────────────────────
3083
3084    /// Backend that wraps TrackingBackend but can be configured to fail label ops.
3085    struct LabelFailingBackend {
3086        inner: TrackingBackend,
3087        /// If set, set_labels returns this error.
3088        set_labels_err: Option<String>,
3089    }
3090
3091    impl LabelFailingBackend {
3092        fn new() -> Self {
3093            Self {
3094                inner: TrackingBackend::new(),
3095                set_labels_err: None,
3096            }
3097        }
3098        fn with_set_labels_err(mut self, err: impl Into<String>) -> Self {
3099            self.set_labels_err = Some(err.into());
3100            self
3101        }
3102    }
3103
3104    impl Default for LabelFailingBackend {
3105        fn default() -> Self {
3106            Self::new()
3107        }
3108    }
3109
3110    #[async_trait]
3111    impl ExternalBackend for LabelFailingBackend {
3112        fn name(&self) -> &'static str {
3113            "label_failing"
3114        }
3115        async fn create_task(
3116            &self,
3117            _t: &str,
3118            _b: &str,
3119            _l: &[String],
3120        ) -> anyhow::Result<ExternalId> {
3121            Ok(ExternalId("new".to_string()))
3122        }
3123        async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
3124            self.inner.get_task(id).await
3125        }
3126        async fn list_by_status(&self, _s: Status) -> anyhow::Result<Vec<ExternalTask>> {
3127            Ok(vec![])
3128        }
3129        async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
3130            Ok(vec![])
3131        }
3132        async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
3133            self.inner.post_comment(id, body).await
3134        }
3135        async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
3136            if let Some(ref err) = self.set_labels_err {
3137                return Err(anyhow::anyhow!("{err}"));
3138            }
3139            self.inner.set_labels(id, labels).await
3140        }
3141        async fn remove_label(&self, id: &ExternalId, label: &str) -> anyhow::Result<()> {
3142            self.inner.remove_label(id, label).await
3143        }
3144        async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
3145            Ok(vec![])
3146        }
3147        async fn create_sub_task(
3148            &self,
3149            parent: &ExternalId,
3150            title: &str,
3151            body: &str,
3152            labels: &[String],
3153        ) -> anyhow::Result<ExternalId> {
3154            self.inner
3155                .create_sub_task(parent, title, body, labels)
3156                .await
3157        }
3158        async fn ensure_status_label(&self, label: &str) -> anyhow::Result<()> {
3159            self.inner.ensure_status_label(label).await
3160        }
3161        async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
3162            self.inner.update_status(id, status).await
3163        }
3164        async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
3165            self.inner.get_authenticated_user().await
3166        }
3167        async fn get_mentions(&self, _s: &str) -> anyhow::Result<Vec<Mention>> {
3168            Ok(vec![])
3169        }
3170        async fn health_check(&self) -> anyhow::Result<()> {
3171            self.inner.health_check().await
3172        }
3173    }
3174
3175    /// `update_agent_label_after_reroute` returns `false` when `set_labels` fails.
3176    ///
3177    /// This tests the extracted helper directly, bypassing the full `run_with_context`
3178    /// path (which would fail earlier on worktree setup in a unit-test environment).
3179    #[tokio::test]
3180    async fn update_agent_label_returns_false_on_set_labels_failure() {
3181        let task = ExternalTask {
3182            id: ExternalId("test-reroute-1".to_string()),
3183            title: "Test".to_string(),
3184            body: "body".to_string(),
3185            state: "open".to_string(),
3186            labels: vec![],
3187            author: "bot".to_string(),
3188            created_at: "2026-01-01T00:00:00Z".to_string(),
3189            updated_at: "2026-01-01T00:00:00Z".to_string(),
3190            url: String::new(),
3191        };
3192        let backend: Arc<dyn ExternalBackend> =
3193            Arc::new(LabelFailingBackend::new().with_set_labels_err("network error"));
3194
3195        let result =
3196            update_agent_label_after_reroute("owner/repo", &task, &backend, "claude", "codex")
3197                .await;
3198
3199        assert!(
3200            !result,
3201            "update_agent_label_after_reroute should return false when set_labels fails"
3202        );
3203    }
3204
3205    /// `update_agent_label_after_reroute` returns `true` when `set_labels` succeeds.
3206    #[tokio::test]
3207    async fn update_agent_label_returns_true_on_success() {
3208        let task = ExternalTask {
3209            id: ExternalId("test-reroute-2".to_string()),
3210            title: "Test".to_string(),
3211            body: "body".to_string(),
3212            state: "open".to_string(),
3213            labels: vec![],
3214            author: "bot".to_string(),
3215            created_at: "2026-01-01T00:00:00Z".to_string(),
3216            updated_at: "2026-01-01T00:00:00Z".to_string(),
3217            url: String::new(),
3218        };
3219        // LabelFailingBackend with no injected error delegates to TrackingBackend (always Ok).
3220        let backend: Arc<dyn ExternalBackend> = Arc::new(LabelFailingBackend::new());
3221
3222        let result =
3223            update_agent_label_after_reroute("owner/repo", &task, &backend, "claude", "codex")
3224                .await;
3225
3226        // ensure_label is skipped in #[cfg(test)], set_labels succeeds via mock.
3227        assert!(
3228            result,
3229            "update_agent_label_after_reroute should return true when set_labels succeeds"
3230        );
3231    }
3232
3233    // ── NDJSON envelope fallback (issue #3078) ────────────────────────────────
3234
3235    /// Pattern A: agent emits domain JSON as result (no `status` field).
3236    /// NDJSON envelope says is_error=false, subtype=success.
3237    /// Was previously returning InvalidResponse → task re-ran.
3238    #[test]
3239    fn parse_success_output_ndjson_envelope_fallback_domain_json_result() {
3240        let domain_result = r#"{"file_path":"md/journal/2026-05-08-morning-briefing.md","summary":"Morning briefing filed","health":{"status":"ok"},"priorities":[]}"#;
3241        let ndjson = format!(
3242            r#"{{"type":"result","subtype":"success","is_error":false,"terminal_reason":"completed","result":{domain_result:?},"usage":{{"input_tokens":3000,"output_tokens":400}}}}"#
3243        );
3244
3245        let runner = agents::get_runner("glm");
3246        let parsed = parse_success_output("149200", "glm", &*runner, &ndjson)
3247            .expect("should synthesize done from NDJSON envelope when result is domain JSON");
3248
3249        assert_eq!(parsed.response.status, "done");
3250        assert!(
3251            parsed.response.summary.contains("file_path") || !parsed.response.summary.is_empty(),
3252            "summary should be populated from domain JSON result text"
3253        );
3254        assert_eq!(parsed.input_tokens, Some(3000));
3255        assert_eq!(parsed.output_tokens, Some(400));
3256    }
3257
3258    /// Grok-style envelope: no terminal_reason, subtype=success.
3259    #[test]
3260    fn parse_success_output_envelope_fallback_without_terminal_reason() {
3261        let receipt = r#"{"ok":true,"report":"notes.md","commit":"abc"}"#;
3262        let ndjson = format!(
3263            r#"{{"type":"result","subtype":"success","is_error":false,"stop_reason":"end_turn","result":{receipt:?},"usage":{{"input_tokens":3000,"output_tokens":400}}}}"#
3264        );
3265        let runner = agents::get_runner("grok");
3266        let parsed = parse_success_output("1", "grok", &*runner, &ndjson).expect("done");
3267        assert_eq!(parsed.response.status, "done");
3268        assert_eq!(parsed.input_tokens, Some(3000));
3269    }
3270
3271    /// Pattern B: agent emits plain prose as result, no completion keywords.
3272    /// NDJSON envelope says is_error=false, subtype=success.
3273    /// Was previously returning InvalidResponse → task re-ran.
3274    #[test]
3275    fn parse_success_output_ndjson_envelope_fallback_plain_prose_result() {
3276        let prose = "PR #1008 now has zero diff vs main. The reverted ledger changes are gone and the branch is clean.";
3277        let ndjson = format!(
3278            r#"{{"type":"result","subtype":"success","is_error":false,"terminal_reason":"completed","result":{prose:?},"usage":{{"input_tokens":2500,"output_tokens":300}}}}"#
3279        );
3280
3281        let runner = agents::get_runner("claude");
3282        let parsed = parse_success_output("149164", "claude", &*runner, &ndjson)
3283            .expect("should synthesize done from NDJSON envelope when result is plain prose");
3284
3285        assert_eq!(parsed.response.status, "done");
3286        assert_eq!(parsed.response.summary, prose);
3287        assert_eq!(parsed.input_tokens, Some(2500));
3288        assert_eq!(parsed.output_tokens, Some(300));
3289    }
3290
3291    /// Verify that is_error=true in NDJSON envelope still returns AgentFailed,
3292    /// not "done" — the envelope fallback must not override explicit errors.
3293    #[test]
3294    fn parse_success_output_ndjson_envelope_fallback_skipped_when_is_error_true() {
3295        let ndjson = r#"{"type":"result","subtype":"error","is_error":true,"result":"Something went wrong with no status"}"#;
3296
3297        let runner = agents::get_runner("claude");
3298        let err = parse_success_output("149999", "claude", &*runner, ndjson)
3299            .expect_err("is_error=true should return AgentFailed");
3300
3301        assert!(matches!(err, agents::AgentError::AgentFailed { .. }));
3302    }
3303
3304    fn tool_pair(id: &str, name: &str, cmd: &str, is_error: bool) -> String {
3305        format!(
3306            "{}\n{}\n",
3307            serde_json::json!({"type":"assistant","message":{"content":[{"type":"tool_use","id":id,"name":name,"input":{"command":cmd}}]}}),
3308            serde_json::json!({"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":id,"is_error":is_error,"content":"x"}]}}),
3309        )
3310    }
3311
3312    #[test]
3313    fn salvage_wrapup_accepts_commit_then_cleanup() {
3314        let raw = format!(
3315            "{}{}",
3316            tool_pair("1", "run_terminal_cmd", "git commit -m x", false),
3317            tool_pair("2", "run_terminal_cmd", "git status", false)
3318        );
3319        assert!(salvage_wrapup(&raw).is_some());
3320    }
3321
3322    #[test]
3323    fn salvage_wrapup_rejects_edit_after_commit_or_unanswered_call() {
3324        let edit = format!(
3325            "{}{}",
3326            tool_pair("1", "run_terminal_cmd", "git commit -m x", false),
3327            tool_pair("2", "search_replace", "", false)
3328        );
3329        assert!(salvage_wrapup(&edit).is_none());
3330        let pending = format!(
3331            "{}{}",
3332            tool_pair("1", "run_terminal_cmd", "git commit -m x", false),
3333            serde_json::json!({"type":"assistant","message":{"content":[{"type":"tool_use","id":"2","name":"run_terminal_cmd","input":{"command":"cargo test"}}]}})
3334        );
3335        assert!(salvage_wrapup(&pending).is_none());
3336        assert!(salvage_wrapup(&tool_pair("1", "run_terminal_cmd", "ls", false)).is_none());
3337    }
3338
3339    // ── terminal_reason:completed + exit 1 detection (issue #3087) ─────────────
3340
3341    /// kimi/minimax sometimes emit valid NDJSON with terminal_reason=completed
3342    /// but exit 1. In that case parse_success_output may fail (e.g. empty result
3343    /// text that synthesize can't parse), then parse_session_output would
3344    /// normally fall back to classify_error — grabbing a garbled JSON tail and
3345    /// producing a spurious Unknown/Auth error. The fix intercepts this and
3346    /// returns InvalidResponse (soft error, re-route) instead.
3347    #[test]
3348    fn parse_session_output_exit0_without_envelope_is_interrupted() {
3349        let session = session::SessionOutput {
3350            exit_code: 0,
3351            raw_stdout: "\u{4}\u{4}\u{4} truncated pane junk".to_string(),
3352            raw_stderr: String::new(),
3353            elapsed_secs: Some(180),
3354            sandbox_error: None,
3355        };
3356
3357        let runner = agents::get_runner("claude");
3358        let err = parse_session_output("3876", "claude", &*runner, &session)
3359            .expect_err("missing envelope should be an error");
3360        assert!(
3361            matches!(err, agents::AgentError::Interrupted { .. }),
3362            "exit 0 with no result envelope should be Interrupted, got {err:?}"
3363        );
3364    }
3365
3366    #[test]
3367    fn parse_session_output_exit1_with_terminal_reason_completed_returns_invalid_response() {
3368        // Empty result text causes synthesize_response_from_text to return None
3369        // (empty/whitespace-only input), so parse_success_output returns
3370        // InvalidResponse. With the fix, parse_session_output intercepts the
3371        // "terminal_reason":"completed" signal before classify_error and
3372        // returns InvalidResponse (re-route) instead of a garbled error.
3373        let ndjson = r#"{"type":"result","subtype":"success","is_error":false,"terminal_reason":"completed","result":"","usage":{"input_tokens":1000,"output_tokens":100}}"#;
3374        let session = session::SessionOutput {
3375            exit_code: 1,
3376            raw_stdout: ndjson.to_string(),
3377            raw_stderr: String::new(),
3378            elapsed_secs: Some(30),
3379            sandbox_error: None,
3380        };
3381
3382        let runner = agents::get_runner("claude");
3383        let result = parse_session_output("149298", "claude", &*runner, &session);
3384
3385        let err = result.expect_err("should be an error, not Ok");
3386        assert!(
3387            matches!(err, agents::AgentError::InvalidResponse { .. }),
3388            "should be InvalidResponse, got {err:?}"
3389        );
3390    }
3391
3392    // ── GLM cost-telemetry exit-1 detection (issue #3094) ──────────────────────
3393
3394    /// GLM exits with code 1 but stdout is only cost telemetry JSON (contains
3395    /// "costUSD":). This must return InvalidResponse (soft re-route) instead of
3396    /// a hard Failed classification with spurious agent cooldown.
3397    #[test]
3398    fn parse_session_output_exit1_with_cost_telemetry_returns_invalid_response() {
3399        // GLM emits cost/usage JSON on clean exit; exit code 1 is a false failure.
3400        let telemetry = r#"{"type":"result","subtype":"success","is_error":false,"result":"","costUSD":0.042,"usage":{"input_tokens":5000,"output_tokens":800}}"#;
3401        let session = session::SessionOutput {
3402            exit_code: 1,
3403            raw_stdout: telemetry.to_string(),
3404            raw_stderr: String::new(),
3405            elapsed_secs: Some(120),
3406            sandbox_error: None,
3407        };
3408
3409        let runner = agents::get_runner("glm");
3410        let result = parse_session_output("149347", "glm", &*runner, &session);
3411
3412        let err = result.expect_err("should be an error, not Ok");
3413        assert!(
3414            matches!(err, agents::AgentError::InvalidResponse { .. }),
3415            "glm cost-telemetry exit-1 should return InvalidResponse, got {err:?}"
3416        );
3417    }
3418
3419    /// GLM with costUSD AND is_error:true — must propagate AgentFailed, not
3420    /// silently swallow the error via the cost-telemetry guard.
3421    #[test]
3422    fn parse_session_output_exit1_cost_telemetry_with_is_error_true_propagates_failure() {
3423        let telemetry = r#"{"type":"result","subtype":"error","is_error":true,"result":"tool failed","costUSD":0.01,"usage":{"input_tokens":100,"output_tokens":10}}"#;
3424        let session = session::SessionOutput {
3425            exit_code: 1,
3426            raw_stdout: telemetry.to_string(),
3427            raw_stderr: String::new(),
3428            elapsed_secs: Some(15),
3429            sandbox_error: None,
3430        };
3431
3432        let runner = agents::get_runner("glm");
3433        let result = parse_session_output("149348", "glm", &*runner, &session);
3434
3435        let err = result.expect_err("should be an error");
3436        assert!(
3437            matches!(err, agents::AgentError::AgentFailed { .. }),
3438            "is_error=true with costUSD must still return AgentFailed, got {err:?}"
3439        );
3440    }
3441
3442    /// terminal_reason=completed must NOT override actual error conditions.
3443    /// When is_error=true in the NDJSON envelope, AgentFailed must be returned
3444    /// even if terminal_reason=completed is also present. Only kimi/minimax
3445    /// agents use find_claude_result which extracts is_error, so use "kimi"
3446    /// as agent_name.
3447    #[test]
3448    fn parse_session_output_exit1_preserves_error_when_is_error_true() {
3449        let ndjson = r#"{"type":"result","subtype":"error","is_error":true,"terminal_reason":"completed","result":"failed"}"#;
3450        let session = session::SessionOutput {
3451            exit_code: 1,
3452            raw_stdout: ndjson.to_string(),
3453            raw_stderr: String::new(),
3454            elapsed_secs: Some(30),
3455            sandbox_error: None,
3456        };
3457
3458        let runner = agents::get_runner("kimi");
3459        let result = parse_session_output("149299", "kimi", &*runner, &session);
3460
3461        let err = result.expect_err("should be an error");
3462        assert!(
3463            matches!(err, agents::AgentError::AgentFailed { .. }),
3464            "is_error=true should return AgentFailed, got {err:?}"
3465        );
3466    }
3467
3468    /// RateLimit with terminal_reason=completed must be propagated as RateLimit,
3469    /// not downgraded to InvalidResponse. This covers claude session-limit errors
3470    /// that include "terminal_reason":"completed" in their NDJSON envelope — the
3471    /// terminal_reason guard was designed for kimi/minimax/glm false-positive
3472    /// exit codes but was intercepting classified errors like RateLimit.
3473    #[test]
3474    fn parse_session_output_exit1_with_rate_limit_and_terminal_reason_completed() {
3475        let ndjson = r#"{"type":"result","is_error":true,"terminal_reason":"completed","result":"You've hit your session limit · resets 2:20pm (America/Sao_Paulo)","usage":{"input_tokens":0,"output_tokens":0}}"#;
3476        let session = session::SessionOutput {
3477            exit_code: 1,
3478            raw_stdout: ndjson.to_string(),
3479            raw_stderr: String::new(),
3480            elapsed_secs: Some(120),
3481            sandbox_error: None,
3482        };
3483
3484        let runner = agents::get_runner("claude");
3485        let result = parse_session_output("152895", "claude", &*runner, &session);
3486
3487        let err = result.expect_err("rate-limit should be an error");
3488        assert!(
3489            matches!(err, agents::AgentError::RateLimit { .. }),
3490            "session limit should return RateLimit, not downgraded, got {err:?}"
3491        );
3492    }
3493}