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