Skip to main content

orch/engine/runner/
mod.rs

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