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