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