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