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