Skip to main content

orch/engine/runner/
mod.rs

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