Skip to main content

orch/engine/runner/
mod.rs

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