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