1pub mod agent;
19pub mod agents;
20pub mod commit;
21pub mod context;
22pub mod cow;
23pub mod diff;
24pub mod direct;
25pub mod fallback;
26pub mod git_ops;
27pub mod response;
28pub mod response_handler;
29pub mod sandbox;
30pub mod session;
31pub mod task_init;
32pub mod worktree;
33
34use crate::backends::{ExternalBackend, ExternalTask, Status};
35use crate::config;
36use crate::engine;
37use crate::engine::cleanup;
38use crate::engine::cooldown;
39use crate::engine::router::RouteResult;
40use crate::engine::tasks;
41use crate::engine::tasks::is_internal_id;
42#[cfg(not(test))]
43use crate::github::http;
44use crate::github::http::GhHttp;
45use crate::home;
46use crate::parser;
47use crate::parser::AgentResponse;
48use crate::parser::Delegation;
49use crate::status_heuristics;
50use crate::store;
51use crate::store::InsertTaskMetric;
52use crate::store::RunTokenUsage;
53use crate::store::TaskStatus;
54use crate::store::TaskStore;
55use crate::tmux::TmuxManager;
56use anyhow::Context;
57use chrono::Utc;
58pub use response::WeightSignal;
59use serde_json::Value;
60use std::collections::HashSet;
61use std::env;
62use std::fmt;
63use std::fmt::Debug;
64use std::fmt::Formatter;
65use std::path::PathBuf;
66use std::slice;
67use std::sync::Arc;
68use tokio::fs;
69use tokio::task;
70
71#[derive(Debug, Clone)]
73pub struct RunAudit {
74 pub stdout: String,
75 pub stderr: String,
76 pub parsed_response: String,
77 pub outcome: String,
78 pub error: String,
79 pub input_tokens: u64,
80 pub output_tokens: u64,
81 pub total_cost_usd: f64,
82 pub duration_secs: f64,
83}
84
85#[derive(Debug, Clone)]
87pub struct RunExecution {
88 pub status: String,
89 pub exit_code: Option<i32>,
90 pub audit: RunAudit,
91}
92
93fn serialize_parsed_response(
94 parse_result: &Result<agents::ParsedResponse, agents::AgentError>,
95 raw_stdout: &str,
96) -> String {
97 match parse_result {
98 Ok(parsed) => serde_json::json!({
99 "response": parsed.response,
100 "input_tokens": parsed.input_tokens,
101 "output_tokens": parsed.output_tokens,
102 "duration_ms": parsed.duration_ms,
103 })
104 .to_string(),
105 Err(agents::AgentError::InvalidResponse { raw }) => {
106 serde_json::json!({"raw": raw}).to_string()
107 }
108 Err(_) => serde_json::json!({"raw": raw_stdout}).to_string(),
109 }
110}
111
112fn classify_run_outcome(
113 status: &str,
114 parse_result: &Result<agents::ParsedResponse, agents::AgentError>,
115 push_failed: bool,
116) -> &'static str {
117 if push_failed {
118 return "push_failed";
119 }
120 match parse_result {
121 Err(agents::AgentError::Timeout { .. }) => "timeout",
122 Err(agents::AgentError::Interrupted { .. }) => "interrupted",
123 Err(agents::AgentError::RateLimit { message }) => {
128 cooldown::detect_credit_exhaustion_outcome(message).unwrap_or("rate_limit")
129 }
130 Err(agents::AgentError::Auth { message }) => {
131 cooldown::detect_credit_exhaustion_outcome(message).unwrap_or("failed")
132 }
133 Err(agents::AgentError::InvalidResponse { .. }) => "parse_error",
134 Ok(_)
135 if matches!(
136 status,
137 "done" | "completed" | "in_progress" | "in_review" | "needs_review" | "routed"
138 ) =>
139 {
140 "success"
141 }
142 Ok(_) if status == "blocked" => "blocked",
143 Ok(_) if status_heuristics::status_looks_like_descriptive_completion(status) => "success",
146 Err(_) | Ok(_) => "failed",
147 }
148}
149
150fn classify_run_error_type(last_error: &str) -> &'static str {
151 if last_error.is_empty() {
152 "success"
154 } else if last_error.contains("timeout") {
155 "timeout"
156 } else if last_error.contains("interrupted") {
157 "interrupted"
158 } else if last_error.contains("billing cycle") || last_error.contains("monthly quota") {
159 "billing_cycle_exhausted"
160 } else if last_error.contains("rate limit") || last_error.contains("usage limit") {
161 "rate_limit"
162 } else if last_error.contains("auth") {
163 "auth_error"
164 } else if last_error.contains("push failed") {
165 "push_failed"
166 } else if last_error.contains("No commits between") || last_error.contains("no commits") {
167 "no_commits"
168 } else if last_error.contains("create PR failed")
169 || last_error.contains("failed to create pull request")
170 || last_error.contains("pull request creation failed")
171 {
172 "pr_failed"
173 } else if last_error.contains("invalid response") || last_error.contains("parse error") {
174 "parse_error"
175 } else if last_error.contains("exceeded max attempts") {
176 "max_attempts"
177 } else {
178 let lower = last_error.to_ascii_lowercase();
179 if lower.contains("build failed")
180 || lower.contains("tests failed")
181 || lower.contains("test failed")
182 || lower.contains("test suite failed")
183 || lower.contains("lint failed")
184 {
185 "ci_failure"
186 } else {
187 "failed"
188 }
189 }
190}
191
192fn extract_run_tokens(
193 parse_result: &Result<agents::ParsedResponse, agents::AgentError>,
194) -> (u64, u64) {
195 match parse_result {
196 Ok(parsed) => (
197 parsed
198 .input_tokens
199 .or(parsed.response.input_tokens)
200 .unwrap_or(0),
201 parsed
202 .output_tokens
203 .or(parsed.response.output_tokens)
204 .unwrap_or(0),
205 ),
206 Err(_) => (0, 0),
207 }
208}
209
210#[expect(clippy::cast_precision_loss, reason = "a run duration in seconds")]
211fn run_duration_secs(started_at: &chrono::DateTime<Utc>) -> f64 {
212 (Utc::now() - *started_at).num_milliseconds() as f64 / 1000.0
213}
214
215struct RunAuditInput<'a> {
216 task_id: &'a str,
217 status: &'a str,
218 parse_result: &'a Result<agents::ParsedResponse, agents::AgentError>,
219 raw_stdout: &'a str,
220 raw_stderr: &'a str,
221 started_at: &'a chrono::DateTime<Utc>,
222 error_override: Option<String>,
223 elapsed_secs: Option<u64>,
224 push_failed: bool,
225}
226
227fn parse_success_output(
228 task_id: &str,
229 agent_name: &str,
230 agent_runner: &dyn agents::AgentRunner,
231 raw_stdout: &str,
232) -> Result<agents::ParsedResponse, agents::AgentError> {
233 if raw_stdout.contains("\"terminal_reason\":\"completed\"")
240 && !raw_stdout.contains("\"is_error\":true")
241 {
242 let assistant_text = agents::collect_assistant_messages_text(agent_name, raw_stdout);
244 if !assistant_text.is_empty() {
245 if let Ok(response) = parser::parse(&assistant_text) {
246 tracing::debug!(
247 task_id,
248 agent = agent_name,
249 "found AgentResponse JSON in earlier assistant message (terminal_reason:completed)"
250 );
251 return Ok(agents::ParsedResponse {
252 response,
253 input_tokens: None,
254 output_tokens: None,
255 duration_ms: None,
256 });
257 }
258 if let Some(response) = agents::synthesize_response_from_text(&assistant_text) {
259 tracing::warn!(
260 task_id,
261 agent = agent_name,
262 "synthesizing response from assistant message due to terminal_reason:completed"
263 );
264 return Ok(agents::ParsedResponse {
265 response,
266 input_tokens: None,
267 output_tokens: None,
268 duration_ms: None,
269 });
270 }
271 }
272 }
279
280 if let Some(agent_result) = agents::find_agent_result(agent_name, raw_stdout) {
282 if agent_result.is_error {
284 if let Some(e) = agents::patterns::detect_rate_limit(&agent_result.result_text) {
291 return Err(e);
292 }
293 if let Some(e) = agents::patterns::detect_auth_error(&agent_result.result_text) {
294 return Err(e);
295 }
296 if let Some(e) = agents::patterns::detect_model_unavailable(&agent_result.result_text) {
297 return Err(e);
298 }
299 return Err(agents::AgentError::AgentFailed {
300 message: agent_result.result_text,
301 });
302 }
303
304 if let Ok(response) = parser::parse(&agent_result.result_text) {
306 return Ok(agents::ParsedResponse {
307 response,
308 input_tokens: agent_result.input_tokens,
309 output_tokens: agent_result.output_tokens,
310 duration_ms: agent_result.duration_ms,
311 });
312 }
313 let assistant_text = agents::collect_assistant_messages_text(agent_name, raw_stdout);
319 if !assistant_text.is_empty()
320 && let Ok(response) = parser::parse(&assistant_text)
321 {
322 tracing::debug!(
323 task_id,
324 agent = agent_name,
325 "found AgentResponse JSON in earlier assistant message"
326 );
327 return Ok(agents::ParsedResponse {
328 response,
329 input_tokens: agent_result.input_tokens,
330 output_tokens: agent_result.output_tokens,
331 duration_ms: agent_result.duration_ms,
332 });
333 }
334
335 if let Some(response) = agents::synthesize_response_from_text(&agent_result.result_text) {
337 tracing::warn!(
338 task_id,
339 agent = agent_name,
340 "parse failed on agent result, synthesizing response from plain text"
341 );
342 return Ok(agents::ParsedResponse {
343 response,
344 input_tokens: agent_result.input_tokens,
345 output_tokens: agent_result.output_tokens,
346 duration_ms: agent_result.duration_ms,
347 });
348 }
349
350 if !agent_result.is_error
363 && result_envelope_completed(raw_stdout)
364 && !agent_result.result_text.is_empty()
365 {
366 let summary_end = agents::truncate_at_char_boundary(&agent_result.result_text, 500);
367 let summary = agent_result.result_text[..summary_end].to_string();
368 tracing::warn!(
369 task_id,
370 agent = agent_name,
371 "NDJSON envelope indicates success but result lacks AgentResponse \
372 schema — synthesizing done response"
373 );
374 return Ok(agents::ParsedResponse {
375 response: AgentResponse {
376 status: "done".to_string(),
377 summary,
378 ..Default::default()
379 },
380 input_tokens: agent_result.input_tokens,
381 output_tokens: agent_result.output_tokens,
382 duration_ms: agent_result.duration_ms,
383 });
384 }
385 }
386
387 match agent_runner.parse_response(raw_stdout) {
389 Ok(parsed) => Ok(parsed),
390 Err(agents::AgentError::InvalidResponse { raw }) => {
391 if let Some(response) = agents::synthesize_response_from_text(&raw) {
392 tracing::warn!(
393 task_id,
394 agent = agent_name,
395 "parse failed, synthesizing response from plain text"
396 );
397 let agent_result = agents::find_agent_result(agent_name, raw_stdout);
401 let input_tokens = agent_result.as_ref().and_then(|r| r.input_tokens);
402 let output_tokens = agent_result.as_ref().and_then(|r| r.output_tokens);
403 let duration_ms = agent_result.as_ref().and_then(|r| r.duration_ms);
404 Ok(agents::ParsedResponse {
405 response,
406 input_tokens,
407 output_tokens,
408 duration_ms,
409 })
410 } else {
411 Err(agents::AgentError::InvalidResponse { raw })
412 }
413 }
414 Err(err) => Err(err),
415 }
416}
417
418fn result_envelope_line(raw_stdout: &str) -> Option<serde_json::Value> {
421 raw_stdout
422 .lines()
423 .rev()
424 .filter_map(|l| serde_json::from_str::<serde_json::Value>(l.trim()).ok())
425 .find(|v| v.get("type").and_then(|t| t.as_str()) == Some("result"))
426}
427
428fn has_result_envelope(raw_stdout: &str) -> bool {
429 result_envelope_line(raw_stdout).is_some()
430}
431
432fn result_envelope_completed(raw_stdout: &str) -> bool {
433 result_envelope_line(raw_stdout).is_some_and(|v| {
434 v.get("subtype").and_then(|t| t.as_str()) == Some("success")
435 || v.get("terminal_reason").and_then(|t| t.as_str()) == Some("completed")
436 })
437}
438
439fn salvage_wrapup(raw: &str) -> Option<String> {
442 let mut calls: Vec<(String, String, Option<bool>)> = Vec::new();
444 let mut last_text = String::new();
445 for v in raw
446 .lines()
447 .filter_map(|l| serde_json::from_str::<Value>(l.trim()).ok())
448 {
449 let blocks = v.pointer("/message/content").and_then(|c| c.as_array());
450 for b in blocks.into_iter().flatten() {
451 match b.get("type").and_then(|t| t.as_str()) {
452 Some("tool_use") => calls.push((
453 b["name"].as_str().unwrap_or("").to_string(),
454 b.pointer("/input/command")
455 .and_then(|c| c.as_str())
456 .unwrap_or("")
457 .to_string(),
458 None,
459 )),
460 Some("text") if v["type"] == "assistant" => {
461 last_text = b["text"].as_str().unwrap_or("").to_string();
462 }
463 Some("tool_result") => {
464 let ok = b.get("is_error").and_then(serde_json::Value::as_bool) != Some(true);
465 if let Some(c) = calls.iter_mut().find(|c| c.2.is_none()) {
467 c.2 = Some(ok);
468 }
469 }
470 _ => {}
471 }
472 }
473 }
474 let commit = calls
475 .iter()
476 .rposition(|(_, cmd, ok)| cmd.contains("git commit") && *ok == Some(true))?;
477 let tail_ok = calls[commit..].iter().all(|(name, _, ok)| {
478 matches!(name.as_str(), "Bash" | "run_terminal_cmd") && *ok == Some(true)
479 });
480 let lower = last_text.to_lowercase();
481 if !tail_ok || lower.contains("still running") || lower.contains("in progress") {
482 return None;
483 }
484 Some(last_text.chars().take(500).collect())
485}
486
487pub fn parse_session_output(
496 task_id: &str,
497 agent_name: &str,
498 agent_runner: &dyn agents::AgentRunner,
499 session_output: &session::SessionOutput,
500) -> Result<agents::ParsedResponse, agents::AgentError> {
501 let combined_output = format!("{}{}", session_output.raw_stdout, session_output.raw_stderr);
506 if !(session_output.raw_stdout.is_empty() && session_output.raw_stderr.is_empty()) {
507 match parse_success_output(task_id, agent_name, agent_runner, &combined_output) {
508 Ok(parsed) => return Ok(parsed),
509 Err(err) => {
510 if session_output.exit_code == 124
511 && let Some(summary) = salvage_wrapup(&combined_output)
512 {
513 tracing::warn!(
514 task_id,
515 agent = agent_name,
516 "timed out after commit during wrap-up, salvaging as done"
517 );
518 return Ok(agents::ParsedResponse {
519 response: AgentResponse {
520 status: "done".to_string(),
521 summary,
522 ..Default::default()
523 },
524 input_tokens: None,
525 output_tokens: None,
526 duration_ms: None,
527 });
528 }
529 if !matches!(err, agents::AgentError::InvalidResponse { .. }) {
537 return Err(err);
538 }
539 if session_output.exit_code != 0 {
540 let combined = &combined_output;
551 let is_completed_signal =
552 combined.contains("\"terminal_reason\":\"completed\"");
553 let is_cost_telemetry_only = combined.contains("\"costUSD\":")
559 && !combined.contains("\"is_error\":true");
560 if is_completed_signal || is_cost_telemetry_only {
561 let raw_tail = agents::patterns::safe_tail(combined, 300);
562 return Err(agents::AgentError::InvalidResponse {
563 raw: raw_tail.to_string(),
564 });
565 }
566 return Err(agent_runner.classify_error_with_elapsed(
567 session_output.exit_code,
568 &session_output.raw_stdout,
569 &session_output.raw_stderr,
570 session_output.elapsed_secs,
571 ));
572 }
573 if !has_result_envelope(&combined_output) {
574 return Err(agents::AgentError::Interrupted {
575 message: "exited without a result envelope".to_string(),
576 });
577 }
578 return Err(err);
579 }
580 }
581 }
582
583 if session_output.exit_code != 0 {
584 return Err(agent_runner.classify_error_with_elapsed(
585 session_output.exit_code,
586 &session_output.raw_stdout,
587 &session_output.raw_stderr,
588 session_output.elapsed_secs,
589 ));
590 }
591
592 Err(agents::AgentError::Unknown {
593 exit_code: session_output.exit_code,
594 message: "empty-output-exit0: opencode returned exit 0 with empty stdout".to_string(),
595 })
596}
597
598pub struct TaskRunner {
600 repo: String,
602 orch_home: PathBuf,
604 store: Option<Arc<TaskStore>>,
606}
607
608impl Debug for TaskRunner {
609 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
610 f.debug_struct("TaskRunner")
611 .field("repo", &self.repo)
612 .field("orch_home", &self.orch_home)
613 .finish_non_exhaustive()
614 }
615}
616
617impl TaskRunner {
618 pub fn new(repo: String) -> Self {
619 let orch_home = home::orch_home().unwrap_or_else(|e| {
620 tracing::warn!(error = %e, "orch_home() failed — falling back to /tmp/.orch");
621 PathBuf::from("/tmp").join(".orch")
622 });
623
624 Self {
625 repo,
626 orch_home,
627 store: None,
628 }
629 }
630
631 #[must_use]
633 pub fn with_store(mut self, store: Arc<TaskStore>) -> Self {
634 self.store = Some(store);
635 self
636 }
637
638 async fn get_field(&self, task_id: &str, field: &str) -> Option<String> {
640 let task = store::opt_store_get_task(&self.store, &self.repo, task_id).await?;
641 match field {
642 "route_reason" => Some(task.route_reason),
643 "worktree" => Some(task.worktree),
644 "attempts" => Some(task.attempts.to_string()),
645 "delegations" => serde_json::to_string(&task.delegations).ok(),
646 "summary" => Some(task.summary),
647 "last_error" => Some(task.last_error),
648 "agent" => task.agent,
649 _ => None,
650 }
651 }
652
653 async fn build_run_audit(&self, input: RunAuditInput<'_>) -> RunAudit {
654 let last_error = self
660 .get_field(input.task_id, "last_error")
661 .await
662 .filter(|s| !s.is_empty());
663 let outcome =
664 classify_run_outcome(input.status, input.parse_result, input.push_failed).to_string();
665
666 let error = if outcome == "success" {
667 String::new()
668 } else {
669 let error = input
672 .error_override
673 .as_deref()
674 .filter(|s| !s.is_empty())
675 .map(str::to_string)
676 .or_else(|| match input.parse_result {
677 Ok(parsed) => parsed
678 .response
679 .error
680 .as_deref()
681 .filter(|s| !s.is_empty())
682 .map(str::to_string),
683 Err(_) => None,
684 })
685 .or(last_error)
686 .unwrap_or_else(|| match input.parse_result {
687 Ok(_) => String::new(),
688 Err(err) => err.to_string(),
689 });
690
691 if error.is_empty() {
692 if input.status == "blocked" {
693 match input.parse_result {
694 Ok(parsed) if !parsed.response.summary.is_empty() => {
695 parsed.response.summary.clone()
696 }
697 _ => "task blocked without explicit error".to_string(),
698 }
699 } else if matches!(
700 input.status,
701 "done"
702 | "completed"
703 | "in_progress"
704 | "in_review"
705 | "needs_review"
706 | "routed"
707 | "new"
708 ) {
709 String::new()
711 } else {
712 format!("unrecognized status: {}", input.status)
716 }
717 } else {
718 error
719 }
720 };
721
722 let total_cost_usd = match &self.store {
723 Some(_) => {
724 store::get_cost_estimate(&self.store, &self.repo, input.task_id)
725 .await
726 .total_cost_usd
727 }
728 None => 0.0,
729 };
730
731 let (input_tokens, output_tokens) = extract_run_tokens(input.parse_result);
732
733 #[expect(clippy::cast_precision_loss, reason = "a run duration in seconds")]
734 let duration_secs = input
735 .elapsed_secs
736 .map_or_else(|| run_duration_secs(input.started_at), |s| s as f64);
737
738 RunAudit {
739 stdout: input.raw_stdout.to_string(),
740 stderr: input.raw_stderr.to_string(),
741 parsed_response: serialize_parsed_response(input.parse_result, input.raw_stdout),
742 outcome,
743 error,
744 input_tokens,
745 output_tokens,
746 total_cost_usd,
747 duration_secs,
748 }
749 }
750
751 async fn sandbox_blocked(&self, task_id: &str, reason: String) -> RunExecution {
753 tracing::error!(task_id, error = %reason, "blocking task");
754 if let Err(e) = store::store_set_result(
755 &self.store,
756 &self.repo,
757 task_id,
758 &[
759 ("last_error", serde_json::json!(reason)),
760 ("block_reason", serde_json::json!(reason)),
761 ],
762 )
763 .await
764 {
765 tracing::warn!(task_id, err = %e, "failed to write the sandbox block_reason");
766 }
767 RunExecution {
768 status: "blocked".to_string(),
769 exit_code: None,
770 audit: RunAudit {
771 stdout: String::new(),
772 stderr: String::new(),
773 parsed_response: String::new(),
774 outcome: "failed".to_string(),
775 error: reason,
776 input_tokens: 0,
777 output_tokens: 0,
778 total_cost_usd: 0.0,
779 duration_secs: 0.0,
780 },
781 }
782 }
783
784 pub async fn run(
789 &self,
790 task_id: &str,
791 agent: Option<&str>,
792 model: Option<&str>,
793 backend: Option<&dyn ExternalBackend>,
794 started_at: &chrono::DateTime<Utc>,
795 ) -> anyhow::Result<Option<RunExecution>> {
796 tracing::info!(
797 task_id,
798 agent = agent.unwrap_or("default"),
799 model = model.unwrap_or("default"),
800 "starting task execution"
801 );
802
803 let attempts = match task_init::check_guards(task_id, &self.repo, &self.store).await {
805 Ok(task_init::GuardOutcome::Proceed(a)) => a,
806 Ok(task_init::GuardOutcome::Skip) => {
807 return Ok(None);
808 }
809 Ok(task_init::GuardOutcome::MaxAttempts) => {
810 if backend.is_some() {
813 let route_reason = self
816 .get_field(task_id, "route_reason")
817 .await
818 .unwrap_or_default();
819 if route_reason.starts_with("label agent:") {
820 let agent_label = route_reason.trim_start_matches("label ");
821 let gh = GhHttp::new()?;
822 match gh.remove_label(&self.repo, task_id, agent_label).await {
823 Err(e) => {
824 tracing::warn!(task_id, label = agent_label, err = %e, "failed to remove agent label after max attempts");
825 }
826 _ => {
827 tracing::info!(
828 task_id,
829 label = agent_label,
830 "removed forced agent label after max attempts — /retry will use free routing"
831 );
832 }
833 }
834 }
835 }
836 return Ok(Some(RunExecution {
837 status: "blocked".to_string(),
838 exit_code: None,
839 audit: RunAudit {
840 stdout: String::new(),
841 stderr: String::new(),
842 parsed_response: String::new(),
843 outcome: "failed".to_string(),
844 error: "max attempts reached".to_string(),
845 input_tokens: 0,
846 output_tokens: 0,
847 total_cost_usd: 0.0,
848 duration_secs: 0.0,
849 },
850 }));
851 }
852 Err(e) => return Err(e),
853 };
854
855 let project_dir = self.resolve_project_dir().await?;
857
858 let init = task_init::prepare_task(
860 task_id,
861 agent,
862 model,
863 backend,
864 &self.repo,
865 &project_dir,
866 attempts,
867 &self.store,
868 )
869 .await?;
870
871 if init.invocation.sandbox
874 && let Err(e) = sandbox::argv(
875 &init.invocation,
876 &env::temp_dir().join("orch-sandbox-preflight"),
877 )
878 .await
879 {
880 return Ok(Some(self.sandbox_blocked(task_id, e.to_string()).await));
881 }
882
883 let (tmux, tmux_session, session_output) = session::run_agent_session(
885 task_id,
886 &init.invocation,
887 &init.attempt_dir,
888 &self.orch_home,
889 )
890 .await;
891
892 if let Some(reason) = session_output.sandbox_error.clone() {
894 return Ok(Some(self.sandbox_blocked(task_id, reason).await));
895 }
896
897 store::store_touch_updated_at(&self.store, &self.repo, task_id).await;
903
904 if let Some(stored) = store::opt_store_get_task(&self.store, &self.repo, task_id).await {
912 let silence_reset = matches!(
913 stored.status,
914 TaskStatus::New | TaskStatus::Routed | TaskStatus::NeedsReview
915 );
916 if silence_reset {
917 let status_str = stored.status.as_str();
918 tracing::info!(
919 task_id,
920 status = status_str,
921 "task already reset by silence detection — skipping fallback processing"
922 );
923 session::cleanup_session(task_id, &tmux, &tmux_session).await;
924 let silence_err = agents::patterns::classify_from_text(0, "silence detected");
925 let silence_parse: Result<agents::ParsedResponse, agents::AgentError> =
926 Err(silence_err);
927 let audit = self
928 .build_run_audit(RunAuditInput {
929 task_id,
930 status: status_str,
931 parse_result: &silence_parse,
932 raw_stdout: &session_output.raw_stdout,
933 raw_stderr: &session_output.raw_stderr,
934 started_at,
935 error_override: Some(format!("silence detection set task to {status_str}")),
936 elapsed_secs: session_output.elapsed_secs,
937 push_failed: false,
938 })
939 .await;
940 return Ok(Some(RunExecution {
941 status: status_str.to_string(),
942 exit_code: Some(session_output.exit_code),
943 audit,
944 }));
945 }
946 }
947
948 let stdout_len = session_output.raw_stdout.len();
950 let stderr_len = session_output.raw_stderr.len();
951 let stdout_tail: String = session_output
952 .raw_stdout
953 .chars()
954 .rev()
955 .take(500)
956 .collect::<String>()
957 .chars()
958 .rev()
959 .collect();
960 let stderr_tail: String = session_output
961 .raw_stderr
962 .chars()
963 .rev()
964 .take(500)
965 .collect::<String>()
966 .chars()
967 .rev()
968 .collect();
969 tracing::info!(
970 task_id,
971 exit_code = session_output.exit_code,
972 stdout_len,
973 stderr_len,
974 stdout_tail = %stdout_tail,
975 stderr_tail = %stderr_tail,
976 "agent raw output"
977 );
978
979 let agent_runner = agents::get_runner(&init.agent_name);
987 let parse_result =
988 parse_session_output(task_id, &init.agent_name, &*agent_runner, &session_output);
989
990 response_handler::write_result_json(
992 &init.attempt_dir,
993 task_id,
994 &init.agent_name,
995 init.model_name.as_deref(),
996 session_output.exit_code,
997 init.new_attempts,
998 &parse_result,
999 &session_output.raw_stdout,
1000 &session_output.raw_stderr,
1001 )
1002 .await;
1003
1004 let (final_status, push_failed, fallback_error) = match parse_result {
1011 Ok(ref parsed) => {
1012 let (status, push_failed) = response_handler::handle_success(
1013 task_id,
1014 parsed.clone(),
1015 &init.wt,
1016 &init.task_title,
1017 &init.agent_name,
1018 init.model_name.as_deref(),
1019 init.new_attempts,
1020 &self.repo,
1021 &self.store,
1022 &session_output.raw_stdout,
1023 )
1024 .await?;
1025 (status, push_failed, None)
1026 }
1027 Err(ref agent_err) => {
1028 match fallback::handle_error(
1029 task_id,
1030 agent_err,
1031 &init.agent_name,
1032 &*agent_runner,
1033 init.model_name.as_deref(),
1034 init.complexity.as_deref(),
1035 init.new_attempts,
1036 &self.store,
1037 &self.repo,
1038 )
1039 .await?
1040 {
1041 fallback::ErrorHandleResult::EarlyReturn { status } => {
1042 session::cleanup_session(task_id, &tmux, &tmux_session).await;
1044 let audit = self
1052 .build_run_audit(RunAuditInput {
1053 task_id,
1054 status: &status,
1055 parse_result: &parse_result,
1056 raw_stdout: &session_output.raw_stdout,
1057 raw_stderr: &session_output.raw_stderr,
1058 started_at,
1059 error_override: None,
1060 elapsed_secs: session_output.elapsed_secs,
1061 push_failed: false,
1062 })
1063 .await;
1064 return Ok(Some(RunExecution {
1065 status,
1066 exit_code: Some(session_output.exit_code),
1067 audit,
1068 }));
1069 }
1070 fallback::ErrorHandleResult::Continue { status, error } => {
1071 (status, false, Some(error))
1072 }
1073 }
1074 }
1075 };
1076
1077 session::cleanup_session(task_id, &tmux, &tmux_session).await;
1079
1080 if final_status.as_str() == "done"
1084 && let Some(ref st) = self.store
1085 && let Err(e) = cleanup::cleanup_task_worktree(task_id, &self.repo, st).await
1086 {
1087 tracing::warn!(task_id, error = ?e, "worktree cleanup failed after no-op done");
1088 }
1089
1090 let audit = self
1095 .build_run_audit(RunAuditInput {
1096 task_id,
1097 status: &final_status,
1098 parse_result: &parse_result,
1099 raw_stdout: &session_output.raw_stdout,
1100 raw_stderr: &session_output.raw_stderr,
1101 started_at,
1102 error_override: fallback_error,
1103 elapsed_secs: session_output.elapsed_secs,
1104 push_failed,
1105 })
1106 .await;
1107
1108 Ok(Some(RunExecution {
1109 status: final_status.clone(),
1110 exit_code: Some(session_output.exit_code),
1111 audit,
1112 }))
1113 }
1114
1115 #[expect(
1117 clippy::too_many_arguments,
1118 reason = "a metric row needs the agent, model, route, timing and outcome together"
1119 )]
1120 async fn record_metrics(
1121 &self,
1122 task_id: &str,
1123 agent_name: &str,
1124 model_name: Option<&str>,
1125 route_result: Option<&RouteResult>,
1126 started_at: &chrono::DateTime<Utc>,
1127 attempts: u32,
1128 final_status: &str,
1129 error_type: Option<&str>,
1130 ) {
1131 let completed_at = Utc::now();
1132 #[expect(clippy::cast_precision_loss, reason = "a run duration in seconds")]
1133 let duration_seconds = (completed_at - *started_at).num_milliseconds() as f64 / 1000.0;
1134
1135 let outcome = match final_status {
1137 "done" | "in_progress" | "in_review" => "success",
1138 "new" => "rerouted",
1139 "needs_review" => classify_run_error_type(error_type.unwrap_or("")),
1140 _ => "unknown",
1141 };
1142
1143 let complexity = route_result.map(|r| r.complexity.clone());
1144 let files_changed = git_ops::count_changed_files(&PathBuf::from(
1145 self.get_field(task_id, "worktree")
1146 .await
1147 .unwrap_or_default(),
1148 ))
1149 .await
1150 .unwrap_or(0);
1151
1152 if let Some(ref store) = self.store {
1153 let db_error_type: Option<String> = if outcome == "success" {
1155 None
1156 } else {
1157 Some(outcome.to_string())
1158 };
1159
1160 let usage = store::get_token_usage(&self.store, &self.repo, task_id).await;
1162 let cost = store::get_cost_estimate(&self.store, &self.repo, task_id).await;
1163 let input_tokens = if usage.input_tokens > 0 {
1164 Some(i64::try_from(usage.input_tokens).unwrap_or(i64::MAX))
1165 } else {
1166 None
1167 };
1168 let output_tokens = if usage.output_tokens > 0 {
1169 Some(i64::try_from(usage.output_tokens).unwrap_or(i64::MAX))
1170 } else {
1171 None
1172 };
1173 let input_cost = if cost.input_cost_usd > 0.0 {
1174 Some(cost.input_cost_usd)
1175 } else {
1176 None
1177 };
1178 let output_cost = if cost.output_cost_usd > 0.0 {
1179 Some(cost.output_cost_usd)
1180 } else {
1181 None
1182 };
1183 let total_cost = if cost.total_cost_usd > 0.0 {
1184 Some(cost.total_cost_usd)
1185 } else {
1186 None
1187 };
1188
1189 let metric = InsertTaskMetric {
1190 repo: &self.repo,
1191 task_id,
1192 agent: agent_name,
1193 model: model_name,
1194 complexity: complexity.as_deref(),
1195 outcome,
1196 duration_seconds,
1197 started_at,
1198 completed_at: &completed_at,
1199 attempts: i32::try_from(attempts.saturating_add(1)).unwrap_or(i32::MAX),
1200 files_changed: i32::try_from(files_changed).unwrap_or(i32::MAX),
1201 error_type: db_error_type.as_deref(),
1202 input_tokens,
1203 output_tokens,
1204 input_cost_usd: input_cost,
1205 output_cost_usd: output_cost,
1206 total_cost_usd: total_cost,
1207 };
1208
1209 if let Err(e) = store.insert_task_metric(&metric).await {
1210 tracing::error!(task_id, ?e, "failed to record task metrics");
1211 }
1212 }
1213 }
1214
1215 pub async fn run_with_context(
1220 &self,
1221 task: &ExternalTask,
1222 backend: &Arc<dyn ExternalBackend>,
1223 _tmux: &Arc<TmuxManager>,
1224 route_result: Option<&RouteResult>,
1225 ) -> anyhow::Result<WeightSignal> {
1226 let task_id = &task.id.0;
1227 let agent = route_result.map(|r| r.agent.as_str());
1228 let agent_name = agent.unwrap_or("claude").to_string();
1229 let model = route_result.and_then(|r| r.model.as_deref());
1230
1231 let started_at = Utc::now();
1233
1234 let run_audit_id = if let Some(ref store) = self.store {
1238 let attempt: i32 = self
1239 .get_field(task_id, "attempts")
1240 .await
1241 .and_then(|s| s.parse().ok())
1242 .unwrap_or(0)
1243 + 1;
1244 match store.resolve_task_id(&self.repo, task_id).await {
1245 Ok(Some(store_id)) => {
1246 match store
1247 .start_run(&crate::store::StartRun {
1248 task_id: store_id,
1249 attempt,
1250 run_type: "agent",
1251 agent: &agent_name,
1252 model: model.unwrap_or(""),
1253 command: &format!(
1254 "{} --model {}",
1255 agent_name,
1256 model.unwrap_or("default")
1257 ),
1258 prompt: &task.body,
1259 })
1260 .await
1261 {
1262 Ok(run_id) => Some(run_id),
1263 Err(e) => {
1264 tracing::warn!(
1265 task_id,
1266 error = %e,
1267 "failed to record run start in audit trail"
1268 );
1269 None
1270 }
1271 }
1272 }
1273 _ => None,
1274 }
1275 } else {
1276 None
1277 };
1278
1279 let run_result = match self
1282 .run(task_id, agent, model, Some(&**backend), &started_at)
1283 .await
1284 {
1285 Ok(run_result) => run_result,
1286 Err(e) => {
1287 if let Some(run_id) = run_audit_id
1288 && let Some(ref store) = self.store
1289 && let Err(complete_err) = store
1290 .complete_run(&crate::store::CompleteRun {
1291 run_id,
1292 exit_code: Some(-1),
1293 stdout: "",
1294 stderr: "",
1295 parsed: "",
1296 outcome: "failed",
1297 error: &e.to_string(),
1298 tokens: RunTokenUsage::default(),
1299 })
1300 .await
1301 {
1302 tracing::warn!(
1303 task_id,
1304 run_id,
1305 error = %complete_err,
1306 "failed to record run completion in audit trail after runner error"
1307 );
1308 }
1309 return Err(e);
1310 }
1311 };
1312
1313 let (status, exit_code_opt, run_audit) = if let Some(result) = run_result {
1315 (result.status, result.exit_code, result.audit)
1316 } else {
1317 if let Some(run_id) = run_audit_id
1318 && let Some(ref store) = self.store
1319 && let Err(e) = store
1320 .complete_run(&crate::store::CompleteRun {
1321 run_id,
1322 exit_code: Some(-1),
1323 stdout: "",
1324 stderr: "",
1325 parsed: "",
1326 outcome: "aborted",
1327 error: "run skipped by guard",
1328 tokens: RunTokenUsage::default(),
1329 })
1330 .await
1331 {
1332 tracing::warn!(
1333 task_id,
1334 run_id,
1335 error = %e,
1336 "failed to record skipped run completion in audit trail"
1337 );
1338 }
1339 tracing::info!(task_id, "guard skipped task — not posting stale result");
1340 return Ok(WeightSignal::None);
1341 };
1342
1343 let delegations_raw = self
1345 .get_field(task_id, "delegations")
1346 .await
1347 .unwrap_or_default();
1348 if !delegations_raw.is_empty() {
1349 match serde_json::from_str::<Vec<Delegation>>(&delegations_raw) {
1350 Ok(delegations) if !delegations.is_empty() => {
1351 self.process_delegations(task, &delegations, backend)
1352 .await?;
1353 if let Err(e) = store::store_set_result(
1354 &self.store,
1355 &self.repo,
1356 task_id,
1357 &[("delegations", serde_json::json!([]))],
1358 )
1359 .await
1360 {
1361 tracing::warn!(task_id, err = %e, "failed to clear delegations in store");
1362 }
1363 }
1364 Ok(_) => {} Err(e) => {
1366 tracing::error!(task_id, error = %e, "corrupt delegations JSON — clearing");
1367 if let Err(e2) = store::store_set_result(
1368 &self.store,
1369 &self.repo,
1370 task_id,
1371 &[("delegations", serde_json::json!([]))],
1372 )
1373 .await
1374 {
1375 tracing::warn!(task_id, err = %e2, "failed to clear corrupt delegations in store");
1376 }
1377 if let Err(e2) = store::store_set_result(
1378 &self.store,
1379 &self.repo,
1380 task_id,
1381 &[(
1382 "last_error",
1383 serde_json::json!(format!("delegation parse failed: {e}")),
1384 )],
1385 )
1386 .await
1387 {
1388 tracing::warn!(task_id, err = %e2, "failed to write delegation_parse_failed last_error to store");
1389 }
1390 }
1391 }
1392 }
1393
1394 let summary = self.get_field(task_id, "summary").await.unwrap_or_default();
1396 let last_error = self
1397 .get_field(task_id, "last_error")
1398 .await
1399 .unwrap_or_default();
1400
1401 let is_rerouted = status == "new" || status == "routed";
1405 if is_rerouted {
1406 let silence_already_reset = store::opt_store_get_task(&self.store, &self.repo, task_id)
1412 .await
1413 .is_some_and(|t| !matches!(t.status, TaskStatus::InProgress));
1414
1415 if silence_already_reset {
1416 tracing::debug!(
1417 task_id,
1418 "silence detection already set fallback agent — skipping agent/model clear"
1419 );
1420 } else if let Err(e) = store::store_set_result(
1421 &self.store,
1422 &self.repo,
1423 task_id,
1424 &[
1425 ("agent", serde_json::json!("")),
1426 ("model", serde_json::json!("")),
1427 ],
1428 )
1429 .await
1430 {
1431 tracing::warn!(task_id, err = %e, "failed to clear agent/model for reroute in store");
1432 }
1433 }
1434 let is_rate_limit_error = classify_run_error_type(&last_error) == "rate_limit";
1439 let weight_signal = if is_rerouted {
1440 if is_rate_limit_error {
1441 WeightSignal::RateLimited {
1442 agent: agent_name.clone(),
1443 }
1444 } else {
1445 WeightSignal::Rerouted
1450 }
1451 } else if status == "needs_review" {
1452 let has_rate_limit_error = classify_run_error_type(&last_error) == "rate_limit";
1464 if has_rate_limit_error {
1465 if let Some(ref store) = self.store
1467 && let Err(e) = store
1468 .record_rate_limit(&agent_name, "rate", Some(task_id))
1469 .await
1470 {
1471 tracing::warn!(task_id, agent = %agent_name, err = %e, "failed to persist rate_limit event — router health check may miss this signal");
1472 }
1473 cooldown::record_rate_limit(&agent_name, model, &last_error).await;
1477 WeightSignal::RateLimited {
1478 agent: agent_name.clone(),
1479 }
1480 } else {
1481 cooldown::record_agent_success(&agent_name, model.unwrap_or("")).await;
1484 WeightSignal::Success {
1485 agent: agent_name.clone(),
1486 }
1487 }
1488 } else if status == "done" || status == "in_progress" || status == "in_review" {
1489 cooldown::record_agent_success(&agent_name, model.unwrap_or("")).await;
1491 WeightSignal::Success {
1492 agent: agent_name.clone(),
1493 }
1494 } else if status == "blocked" {
1495 WeightSignal::Blocked
1496 } else {
1497 WeightSignal::None
1498 };
1499
1500 {
1502 let attempts: u32 = self
1503 .get_field(task_id, "attempts")
1504 .await
1505 .and_then(|s| s.parse().ok())
1506 .unwrap_or(0);
1507 self.record_metrics(
1508 task_id,
1509 &agent_name,
1510 model,
1511 route_result,
1512 &started_at,
1513 attempts,
1514 &status,
1515 if last_error.is_empty() {
1516 None
1517 } else {
1518 Some(&last_error)
1519 },
1520 )
1521 .await;
1522 }
1523
1524 if let Some(run_id) = run_audit_id
1526 && let Some(ref store) = self.store
1527 && let Err(e) = store
1528 .complete_run(&crate::store::CompleteRun {
1529 run_id,
1530 exit_code: exit_code_opt,
1531 stdout: &run_audit.stdout,
1532 stderr: &run_audit.stderr,
1533 parsed: &run_audit.parsed_response,
1534 outcome: &run_audit.outcome,
1535 error: &run_audit.error,
1536 tokens: RunTokenUsage {
1537 input_tokens: run_audit.input_tokens,
1538 output_tokens: run_audit.output_tokens,
1539 total_cost_usd: run_audit.total_cost_usd,
1540 duration_secs: run_audit.duration_secs,
1541 },
1542 })
1543 .await
1544 {
1545 tracing::warn!(
1546 task_id,
1547 run_id,
1548 error = %e,
1549 "failed to record run completion in audit trail"
1550 );
1551 }
1552
1553 if is_rerouted && !is_internal_id(task_id) {
1556 let new_agent = self.get_field(task_id, "agent").await.unwrap_or_default();
1557 if !new_agent.is_empty() && new_agent != agent_name {
1558 update_agent_label_after_reroute(
1559 &self.repo,
1560 task,
1561 backend,
1562 &agent_name,
1563 &new_agent,
1564 )
1565 .await;
1566 }
1567 }
1568
1569 if is_internal_id(task_id) {
1571 return Ok(weight_signal);
1572 }
1573
1574 let new_status = match status.as_str() {
1576 "done" => Status::Done,
1577 "in_progress" => Status::InProgress,
1578 "in_review" => Status::InReview,
1579 "blocked" => Status::Blocked,
1580 "routed" => Status::Routed, "new" => Status::New, _ => Status::NeedsReview,
1583 };
1584 if let Some(ref store) = self.store
1586 && let Ok(Some(store_id)) = store.resolve_task_id(&self.repo, &task.id.0).await
1587 {
1588 store
1589 .update_status(store_id, tasks::status_to_task_status(new_status))
1590 .await
1591 .context("store-first status update failed in runner")?;
1592 }
1593 if let Err(e) = backend.update_status(&task.id, new_status).await {
1594 tracing::warn!(
1595 task_id,
1596 ?new_status,
1597 err = %e,
1598 "failed to mirror runner status to backend — store is authoritative"
1599 );
1600 }
1601
1602 let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
1603 let mut comment = if !summary.is_empty() {
1604 format!("[{now}] {status}: {summary}")
1605 } else if !last_error.is_empty() {
1606 format!("[{now}] {status}: {last_error}")
1607 } else {
1608 format!("[{now}] {status}")
1609 };
1610
1611 let footer = engine::attribution_footer("Created", &agent_name, model);
1613 comment.push_str(&footer);
1614
1615 backend.post_comment(&task.id, &comment).await?;
1616
1617 Ok(weight_signal)
1618 }
1619
1620 async fn process_delegations(
1626 &self,
1627 parent_task: &ExternalTask,
1628 delegations: &[Delegation],
1629 backend: &Arc<dyn ExternalBackend>,
1630 ) -> anyhow::Result<()> {
1631 let parent_id = &parent_task.id;
1632
1633 let existing_titles: HashSet<String> = match backend.list_all_tasks().await {
1636 Ok(tasks) => tasks.into_iter().map(|t| t.title).collect(),
1637 Err(e) => {
1638 tracing::warn!(err = %e, "failed to fetch tasks for dedup — will create all");
1639 HashSet::new()
1640 }
1641 };
1642
1643 for delegation in delegations {
1644 if existing_titles.contains(&delegation.title) {
1646 tracing::info!(
1647 parent = parent_id.0,
1648 title = delegation.title,
1649 "skipping delegation — issue with same title already exists"
1650 );
1651 continue;
1652 }
1653
1654 let mut labels = delegation.labels.clone();
1656 labels.push("status:new".to_string());
1657
1658 let child_body = format!(
1660 "{}\n\n---\n_Delegated from #{}_{}",
1661 delegation.body,
1662 parent_id.0,
1663 engine::orch_footer()
1664 );
1665
1666 match backend
1667 .create_sub_task(parent_id, &delegation.title, &child_body, &labels)
1668 .await
1669 {
1670 Ok(child_id) => {
1671 tracing::info!(
1672 parent = parent_id.0,
1673 child = child_id.0,
1674 title = delegation.title,
1675 "created delegated subtask"
1676 );
1677 }
1678 Err(e) => {
1679 tracing::error!(
1680 parent = parent_id.0,
1681 title = delegation.title,
1682 err = %e,
1683 "failed to create delegated subtask"
1684 );
1685 }
1686 }
1687 }
1688
1689 if let Some(ref store) = self.store
1691 && let Ok(Some(store_id)) = store.resolve_task_id(&self.repo, &parent_id.0).await
1692 {
1693 store
1694 .update_status(store_id, TaskStatus::Blocked)
1695 .await
1696 .context("store-first: failed to block parent in store")?;
1697 }
1698 if let Err(e) = backend.update_status(parent_id, Status::Blocked).await {
1699 tracing::warn!(
1700 parent = parent_id.0,
1701 err = %e,
1702 "failed to mirror blocked status to backend — store is authoritative"
1703 );
1704 }
1705
1706 let summary = delegations
1708 .iter()
1709 .enumerate()
1710 .map(|(i, d)| format!("{}. {}", i + 1, d.title))
1711 .collect::<Vec<_>>()
1712 .join("\n");
1713
1714 backend
1715 .post_comment(
1716 parent_id,
1717 &format!(
1718 "Delegated {} subtask(s):\n\n{}\n\nParent task is blocked until all subtasks complete.{}",
1719 delegations.len(),
1720 summary,
1721 engine::orch_footer()
1722 ),
1723 )
1724 .await?;
1725
1726 Ok(())
1727 }
1728
1729 async fn resolve_project_dir(&self) -> anyhow::Result<PathBuf> {
1731 if let Ok(dir) = env::var("PROJECT_DIR")
1733 && !dir.is_empty()
1734 {
1735 return Ok(PathBuf::from(dir));
1736 }
1737
1738 if let Ok(paths) = config::get_project_paths() {
1740 for path_str in &paths {
1741 let path = PathBuf::from(path_str);
1742 if let Ok(repo) = config::get_repo_for_project(&path) {
1744 let path_for_check = path.clone();
1746 if repo == self.repo && fs::metadata(&path_for_check).await.is_ok() {
1747 return Ok(path);
1748 }
1749 }
1750 }
1751 }
1752
1753 if let Ok(dir) = config::get("project_dir")
1755 && !dir.is_empty()
1756 {
1757 let path = PathBuf::from(&dir);
1758 let path_for_check = path.clone();
1759 if fs::metadata(&path_for_check).await.is_ok() {
1760 return Ok(path);
1761 }
1762 }
1763
1764 let parts: Vec<&str> = self.repo.split('/').collect();
1766 if parts.len() == 2 {
1767 let bare = self
1768 .orch_home
1769 .join("projects")
1770 .join(parts[0])
1771 .join(format!("{}.git", parts[1]));
1772 let bare_for_check = bare.clone();
1773 if task::spawn_blocking(move || bare_for_check.exists())
1774 .await
1775 .unwrap_or(false)
1776 {
1777 return Ok(bare);
1778 }
1779 }
1780
1781 anyhow::bail!(
1786 "no resolvable project directory for repo {} — not in active config, no bare clone at {}, no legacy project_dir set",
1787 self.repo,
1788 self.orch_home
1789 .join("projects")
1790 .join(self.repo.split('/').next().unwrap_or_default())
1791 .join(format!(
1792 "{}.git",
1793 self.repo.split('/').nth(1).unwrap_or_default()
1794 ))
1795 .display()
1796 )
1797 }
1798}
1799
1800async fn update_agent_label_after_reroute(
1806 #[cfg_attr(
1807 test,
1808 expect(unused_variables, reason = "only the label call outside tests uses it")
1809 )]
1810 repo: &str,
1811 task: &ExternalTask,
1812 backend: &Arc<dyn ExternalBackend>,
1813 old_agent: &str,
1814 new_agent: &str,
1815) -> bool {
1816 let old_label = format!("agent:{old_agent}");
1817 if let Err(e) = backend.remove_label(&task.id, &old_label).await {
1818 tracing::warn!(
1819 task_id = ?task.id,
1820 label = old_label,
1821 error = %e,
1822 "failed to remove old agent label during re-route"
1823 );
1824 }
1825 let new_label = format!("agent:{new_agent}");
1826
1827 #[cfg(not(test))]
1830 {
1831 match GhHttp::new() {
1832 Ok(gh) => {
1833 if gh
1834 .ensure_label(
1835 repo,
1836 &new_label,
1837 http::status_label_color(&new_label),
1838 &format!("Agent: {new_agent}"),
1839 )
1840 .await
1841 .is_err()
1842 {
1843 tracing::warn!(
1844 task_id = ?task.id,
1845 label = %new_label,
1846 "ensure_label failed during agent label update after failover"
1847 );
1848 }
1849 }
1850 Err(e) => {
1851 tracing::warn!(
1852 task_id = ?task.id,
1853 err = %e,
1854 "GhHttp::new() failed — skipping ensure_label"
1855 );
1856 }
1857 }
1858 }
1859
1860 if let Err(e) = backend
1861 .set_labels(&task.id, slice::from_ref(&new_label))
1862 .await
1863 {
1864 tracing::warn!(
1865 task_id = ?task.id,
1866 label = %new_label,
1867 error = %e,
1868 "set_labels failed during agent label update after failover"
1869 );
1870 return false;
1871 }
1872 true
1873}
1874
1875#[cfg(test)]
1876mod tests {
1877 use super::*;
1878 use crate::backends::{ExternalId, ExternalTask, Mention, Status};
1879 use crate::engine::runner::agents::patterns::safe_tail as safe_utf8_tail;
1880 use crate::parser::AgentResponse;
1881 use crate::store::{NewTask, TaskStore};
1882 use async_trait::async_trait;
1883 use chrono::Utc;
1884 use std::sync::{Arc, Mutex};
1885 use tempfile::TempDir;
1886 use tokio::process::Command;
1887
1888 static ENV_LOCK: Mutex<()> = Mutex::new(());
1889
1890 fn ok_parse_result(status: &str) -> agents::ParsedResponse {
1891 agents::ParsedResponse {
1892 response: AgentResponse {
1893 status: status.to_string(),
1894 summary: String::new(),
1895 accomplished: vec![],
1896 remaining: vec![],
1897 files: vec![],
1898 error: None,
1899 input_tokens: None,
1900 output_tokens: None,
1901 learnings: vec![],
1902 delegations: vec![],
1903 },
1904 input_tokens: None,
1905 output_tokens: None,
1906 duration_ms: None,
1907 }
1908 }
1909
1910 #[test]
1913 fn safe_utf8_tail_short_string_returned_as_is() {
1914 assert_eq!(safe_utf8_tail("hello", 100), "hello");
1915 assert_eq!(safe_utf8_tail("", 10), "");
1916 }
1917
1918 #[test]
1919 fn safe_utf8_tail_truncates_ascii() {
1920 let s = "abcdefghij"; assert_eq!(safe_utf8_tail(s, 5), "fghij");
1922 assert_eq!(safe_utf8_tail(s, 10), "abcdefghij");
1923 }
1924
1925 #[test]
1926 fn safe_utf8_tail_handles_multibyte_boundary() {
1927 let s = "日本語";
1929 assert_eq!(s.len(), 9);
1930 let tail = safe_utf8_tail(s, 8);
1934 assert!(
1935 s.ends_with(tail),
1936 "tail must be a valid suffix of the original"
1937 );
1938 assert!(
1939 str::from_utf8(tail.as_bytes()).is_ok(),
1940 "tail must be valid UTF-8"
1941 );
1942 }
1943
1944 #[test]
1945 fn safe_utf8_tail_handles_mixed_ascii_and_multibyte() {
1946 let s = "hello日本語world"; for max_bytes in [1, 3, 5, 7, 11, 13, 20, 100] {
1948 let tail = safe_utf8_tail(s, max_bytes);
1949 assert!(
1950 str::from_utf8(tail.as_bytes()).is_ok(),
1951 "max_bytes={max_bytes}: tail must be valid UTF-8"
1952 );
1953 assert!(
1954 s.ends_with(tail),
1955 "max_bytes={max_bytes}: tail must be a suffix of original"
1956 );
1957 }
1958 }
1959
1960 #[test]
1961 fn safe_utf8_tail_exact_boundary() {
1962 let s = "abcdef"; assert_eq!(safe_utf8_tail(s, 6), "abcdef"); assert_eq!(safe_utf8_tail(s, 0), ""); }
1966
1967 #[test]
1968 fn parse_success_output_synthesizes_done_from_plain_text() {
1969 let runner = agents::get_runner("claude");
1970 let parsed = parse_success_output(
1971 "123",
1972 "claude",
1973 &*runner,
1974 "No open positions, no trade executions, no conditions change.",
1975 )
1976 .unwrap();
1977
1978 assert_eq!(parsed.response.status, "done");
1979 assert_eq!(
1980 parsed.response.summary,
1981 "No open positions, no trade executions, no conditions change."
1982 );
1983 }
1984
1985 #[test]
1986 fn parse_success_output_synthesizes_needs_review_from_error_text() {
1987 let runner = agents::get_runner("claude");
1988 let parsed =
1989 parse_success_output("123", "claude", &*runner, "Failed to update branch").unwrap();
1990
1991 assert_eq!(parsed.response.status, "needs_review");
1992 assert_eq!(
1993 parsed.response.error.as_deref(),
1994 Some("agent returned plain text instead of JSON")
1995 );
1996 }
1997
1998 #[test]
1999 fn parse_success_output_keeps_empty_text_invalid() {
2000 let runner = agents::get_runner("claude");
2001 let err = parse_success_output("123", "claude", &*runner, "").unwrap_err();
2002
2003 assert!(matches!(err, agents::AgentError::InvalidResponse { .. }));
2004 }
2005
2006 #[test]
2007 fn parse_success_output_preserves_tokens_from_ndjson() {
2008 let ndjson = r#"{"type":"system","subtype":"init","session_id":"abc"}
2013{"type":"assistant","message":{"content":[{"type":"text","text":"Done."}],"usage":{"input_tokens":100,"output_tokens":50}}}
2014{"type":"result","subtype":"success","is_error":false,"duration_ms":5000,"result":"All changes committed successfully","usage":{"input_tokens":1234,"output_tokens":567}}"#;
2015 let runner = agents::get_runner("claude");
2016 let parsed = parse_success_output("456", "claude", &*runner, ndjson).unwrap();
2017
2018 assert_eq!(parsed.response.status, "done");
2019 assert_eq!(parsed.input_tokens, Some(1234));
2020 assert_eq!(parsed.output_tokens, Some(567));
2021 assert_eq!(parsed.duration_ms, Some(5000));
2022 }
2023
2024 #[test]
2025 fn parse_success_output_preserves_tokens_from_minimax_ndjson() {
2026 let ndjson = r#"{"type":"system","subtype":"init"}
2028{"type":"assistant","message":{"content":[{"type":"text","text":"No open positions."}],"usage":{"input_tokens":2000,"output_tokens":300}}}"#;
2029 let runner = agents::get_runner("minimax");
2030 let parsed = parse_success_output("789", "minimax", &*runner, ndjson).unwrap();
2031
2032 assert_eq!(parsed.response.status, "done");
2033 assert_eq!(parsed.input_tokens, Some(2000));
2034 assert_eq!(parsed.output_tokens, Some(300));
2035 }
2036
2037 #[test]
2038 fn classify_run_error_type_recognizes_specific_patterns() {
2039 let cases = [
2040 ("push failed: remote rejected", "push_failed"),
2041 ("No commits between main and branch", "no_commits"),
2042 ("create PR failed: 422", "pr_failed"),
2043 ("invalid response from agent", "parse_error"),
2044 ("parse error: missing json", "parse_error"),
2045 ("exceeded max attempts while rerouting", "max_attempts"),
2046 ("unit test failed", "ci_failure"),
2047 ("build failed: type mismatch", "ci_failure"),
2048 ("lint failed on warning", "ci_failure"),
2049 ("3 tests failed", "ci_failure"),
2050 ("test suite failed: 3 tests failed", "ci_failure"),
2051 (
2052 "claude interrupted: exited without a result envelope",
2053 "interrupted",
2054 ),
2055 ];
2056
2057 for (input, expected) in cases {
2058 assert_eq!(classify_run_error_type(input), expected, "input={input}");
2059 }
2060 }
2061
2062 #[test]
2063 fn classify_run_error_type_falls_back_for_unknown_errors() {
2064 assert_eq!(classify_run_error_type("something unexpected"), "failed");
2065 }
2066
2067 #[test]
2068 fn classify_run_error_type_connection_error_mentioning_pull_request_is_not_pr_failed() {
2069 assert_eq!(
2072 classify_run_error_type("failed to fetch pull request details from GitHub"),
2073 "failed"
2074 );
2075 assert_eq!(
2076 classify_run_error_type("connection reset while reading pull request data"),
2077 "failed"
2078 );
2079 }
2080
2081 #[test]
2082 fn classify_run_error_type_url_with_test_substring_is_not_ci_failure() {
2083 assert_eq!(
2085 classify_run_error_type(
2086 "failed to connect to api.github.com/repos/test-org/repo/pulls/123"
2087 ),
2088 "failed"
2089 );
2090 assert_eq!(
2092 classify_run_error_type("context deadline exceeded testing connection"),
2093 "failed"
2094 );
2095 }
2096
2097 #[test]
2098 fn classify_run_error_type_usage_without_limit_is_not_rate_limit() {
2099 assert_eq!(
2101 classify_run_error_type("token usage: 1245 tokens"),
2102 "failed"
2103 );
2104 assert_eq!(
2105 classify_run_error_type("incorrect usage of git command"),
2106 "failed"
2107 );
2108 assert_eq!(
2110 classify_run_error_type("exceeded usage limit for this billing period"),
2111 "rate_limit"
2112 );
2113 }
2114
2115 #[test]
2116 fn classify_run_error_type_billing_cycle_is_not_auth_error() {
2117 assert_eq!(
2120 classify_run_error_type(
2121 "You've reached your usage limit for this billing cycle. \
2122 Your quota will be refreshed in the next cycle."
2123 ),
2124 "billing_cycle_exhausted"
2125 );
2126 assert_eq!(
2127 classify_run_error_type("quota exhausted for billing cycle"),
2128 "billing_cycle_exhausted"
2129 );
2130 assert_eq!(
2132 classify_run_error_type("billing account suspended"),
2133 "failed"
2134 );
2135 }
2136
2137 #[test]
2138 fn classify_run_error_type_monthly_quota_is_billing_cycle() {
2139 assert_eq!(
2142 classify_run_error_type(
2143 "opencode failed: agent failed: You have exceeded your monthly quota"
2144 ),
2145 "billing_cycle_exhausted"
2146 );
2147 assert_eq!(
2148 classify_run_error_type("You have exceeded your monthly quota"),
2149 "billing_cycle_exhausted"
2150 );
2151 }
2152
2153 #[test]
2154 fn classify_run_outcome_routed_is_success() {
2155 let parse_result = Ok(ok_parse_result("routed"));
2159 assert_eq!(
2160 classify_run_outcome("routed", &parse_result, false),
2161 "success"
2162 );
2163 }
2164
2165 #[test]
2166 fn classify_run_outcome_blocked_is_blocked() {
2167 let parse_result = Ok(ok_parse_result("blocked"));
2168 assert_eq!(
2169 classify_run_outcome("blocked", &parse_result, false),
2170 "blocked"
2171 );
2172 }
2173
2174 #[test]
2175 fn classify_run_outcome_non_canonical_is_failed() {
2176 let parse_result = Ok(ok_parse_result("fix_deployed"));
2179 assert_eq!(
2180 classify_run_outcome("fix_deployed", &parse_result, false),
2181 "failed"
2182 );
2183 }
2184
2185 #[test]
2186 fn classify_run_outcome_kimi_billing_cycle_is_not_rate_limit() {
2187 let parse_result: Result<agents::ParsedResponse, agents::AgentError> =
2192 Err(agents::AgentError::RateLimit {
2193 message: "Failed to authenticate. API Error: 403 You've reached your usage \
2194 limit for this billing cycle. Your quota will be refreshed soon."
2195 .to_string(),
2196 });
2197 assert_eq!(
2198 classify_run_outcome("needs_review", &parse_result, false),
2199 "billing_cycle_exhausted"
2200 );
2201 }
2202
2203 #[test]
2204 fn classify_run_outcome_generic_rate_limit_is_unaffected() {
2205 let parse_result: Result<agents::ParsedResponse, agents::AgentError> =
2208 Err(agents::AgentError::RateLimit {
2209 message: "429 Too Many Requests".to_string(),
2210 });
2211 assert_eq!(
2212 classify_run_outcome("needs_review", &parse_result, false),
2213 "rate_limit"
2214 );
2215 }
2216
2217 #[test]
2218 fn classify_run_outcome_auth_billing_cycle_is_not_failed() {
2219 let parse_result: Result<agents::ParsedResponse, agents::AgentError> =
2223 Err(agents::AgentError::Auth {
2224 message: "billing cycle exhausted for this account".to_string(),
2225 });
2226 assert_eq!(
2227 classify_run_outcome("needs_review", &parse_result, false),
2228 "billing_cycle_exhausted"
2229 );
2230 }
2231
2232 #[test]
2233 fn classify_run_outcome_generic_auth_error_is_unaffected() {
2234 let parse_result: Result<agents::ParsedResponse, agents::AgentError> =
2235 Err(agents::AgentError::Auth {
2236 message: "401 Unauthorized".to_string(),
2237 });
2238 assert_eq!(
2239 classify_run_outcome("needs_review", &parse_result, false),
2240 "failed"
2241 );
2242 }
2243
2244 #[test]
2245 fn classify_run_outcome_interrupted() {
2246 let parse_result: Result<agents::ParsedResponse, agents::AgentError> =
2247 Err(agents::AgentError::Interrupted {
2248 message: "exited without a result envelope".to_string(),
2249 });
2250 assert_eq!(
2251 classify_run_outcome("new", &parse_result, false),
2252 "interrupted"
2253 );
2254 }
2255
2256 #[tokio::test]
2257 async fn build_run_audit_success_ignores_stale_last_error() {
2258 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2259 let task_id = store
2260 .create(&NewTask {
2261 repo: "owner/repo".to_string(),
2262 origin: "internal".to_string(),
2263 title: "Test".to_string(),
2264 ..Default::default()
2265 })
2266 .await
2267 .unwrap();
2268 store
2269 .set_fields(
2270 task_id,
2271 &[("last_error", serde_json::json!("no error info available"))],
2272 )
2273 .await
2274 .unwrap();
2275
2276 let runner = TaskRunner::new("owner/repo".to_string()).with_store(store);
2277 let parse_result = Ok(ok_parse_result("done"));
2278 let started_at = Utc::now();
2279 let audit = runner
2280 .build_run_audit(RunAuditInput {
2281 task_id: &task_id.to_string(),
2282 status: "done",
2283 parse_result: &parse_result,
2284 raw_stdout: "",
2285 raw_stderr: "",
2286 started_at: &started_at,
2287 error_override: None,
2288 elapsed_secs: Some(1),
2289 push_failed: false,
2290 })
2291 .await;
2292
2293 assert_eq!(audit.outcome, "success");
2294 assert_eq!(audit.error, "");
2295 }
2296
2297 #[tokio::test]
2298 async fn build_run_audit_ok_parse_prefers_parsed_error_over_stale_last_error() {
2299 let cases = [("needs_action", "failed"), ("blocked", "blocked")];
2302 let parsed_error = "passbox Touch ID prompt timed out for cf/read-token";
2303 let stale = "minimax auth error: API Error: 402 insufficient balance (1008), clearing agent/model for re-route";
2304 for (status, expected_outcome) in cases {
2305 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2306 let task_id = store
2307 .create(&NewTask {
2308 repo: "owner/repo".to_string(),
2309 origin: "internal".to_string(),
2310 title: "Test".to_string(),
2311 ..Default::default()
2312 })
2313 .await
2314 .unwrap();
2315 store
2316 .set_fields(task_id, &[("last_error", serde_json::json!(stale))])
2317 .await
2318 .unwrap();
2319
2320 let runner = TaskRunner::new("owner/repo".to_string()).with_store(store);
2321 let parse_result = Ok(agents::ParsedResponse {
2322 response: AgentResponse {
2323 status: status.to_string(),
2324 summary: "cache_stats.py did not run".to_string(),
2325 accomplished: vec![],
2326 remaining: vec![],
2327 files: vec![],
2328 error: Some(parsed_error.to_string()),
2329 input_tokens: None,
2330 output_tokens: None,
2331 learnings: vec![],
2332 delegations: vec![],
2333 },
2334 input_tokens: None,
2335 output_tokens: None,
2336 duration_ms: None,
2337 });
2338 let started_at = Utc::now();
2339 let audit = runner
2340 .build_run_audit(RunAuditInput {
2341 task_id: &task_id.to_string(),
2342 status,
2343 parse_result: &parse_result,
2344 raw_stdout: "",
2345 raw_stderr: "",
2346 started_at: &started_at,
2347 error_override: None,
2348 elapsed_secs: Some(261),
2349 push_failed: false,
2350 })
2351 .await;
2352
2353 assert_eq!(audit.outcome, expected_outcome, "status={status}");
2354 assert_eq!(audit.error, parsed_error, "status={status}");
2355 }
2356 }
2357
2358 #[tokio::test]
2359 async fn build_run_audit_non_success_uses_fallback_error_when_empty() {
2360 let runner = TaskRunner::new("owner/repo".to_string());
2361 let parse_result = Ok(ok_parse_result("new"));
2362 let started_at = Utc::now();
2363 let audit = runner
2364 .build_run_audit(RunAuditInput {
2365 task_id: "1",
2366 status: "new",
2367 parse_result: &parse_result,
2368 raw_stdout: "",
2369 raw_stderr: "",
2370 started_at: &started_at,
2371 error_override: None,
2372 elapsed_secs: Some(1),
2373 push_failed: false,
2374 })
2375 .await;
2376
2377 assert_eq!(audit.outcome, "failed");
2378 assert_eq!(audit.error, "");
2380 }
2381
2382 #[tokio::test]
2383 async fn build_run_audit_non_canonical_status_reports_explicit_error() {
2384 let runner = TaskRunner::new("owner/repo".to_string());
2388 let parse_result = Ok(ok_parse_result("fix_deployed"));
2389 let started_at = Utc::now();
2390 let audit = runner
2391 .build_run_audit(RunAuditInput {
2392 task_id: "1",
2393 status: "fix_deployed",
2394 parse_result: &parse_result,
2395 raw_stdout: "",
2396 raw_stderr: "",
2397 started_at: &started_at,
2398 error_override: None,
2399 elapsed_secs: Some(1),
2400 push_failed: false,
2401 })
2402 .await;
2403
2404 assert_eq!(audit.outcome, "failed");
2405 assert_eq!(audit.error, "unrecognized status: fix_deployed");
2406 }
2407
2408 #[tokio::test]
2409 async fn build_run_audit_canonical_non_success_status_no_error_message() {
2410 for status in &["routed"] {
2413 let runner = TaskRunner::new("owner/repo".to_string());
2414 let parse_result = Ok(ok_parse_result(status));
2415 let started_at = Utc::now();
2416 let audit = runner
2417 .build_run_audit(RunAuditInput {
2418 task_id: "1",
2419 status,
2420 parse_result: &parse_result,
2421 raw_stdout: "",
2422 raw_stderr: "",
2423 started_at: &started_at,
2424 error_override: None,
2425 elapsed_secs: Some(1),
2426 push_failed: false,
2427 })
2428 .await;
2429
2430 assert_eq!(audit.outcome, "success", "status={status}");
2431 assert_eq!(
2432 audit.error, "",
2433 "status={status}: no error for canonical status"
2434 );
2435 }
2436 }
2437
2438 #[tokio::test]
2439 async fn build_run_audit_blocked_uses_summary_when_error_missing() {
2440 let runner = TaskRunner::new("owner/repo".to_string());
2441 let parse_result = Ok(agents::ParsedResponse {
2442 response: AgentResponse {
2443 status: "blocked".to_string(),
2444 summary: "Could not proceed due to missing credentials".to_string(),
2445 accomplished: vec![],
2446 remaining: vec![],
2447 files: vec![],
2448 error: None,
2449 input_tokens: None,
2450 output_tokens: None,
2451 learnings: vec![],
2452 delegations: vec![],
2453 },
2454 input_tokens: None,
2455 output_tokens: None,
2456 duration_ms: None,
2457 });
2458 let started_at = Utc::now();
2459 let audit = runner
2460 .build_run_audit(RunAuditInput {
2461 task_id: "1",
2462 status: "blocked",
2463 parse_result: &parse_result,
2464 raw_stdout: "",
2465 raw_stderr: "",
2466 started_at: &started_at,
2467 error_override: None,
2468 elapsed_secs: Some(1),
2469 push_failed: false,
2470 })
2471 .await;
2472
2473 assert_eq!(audit.outcome, "blocked");
2474 assert_eq!(audit.error, "Could not proceed due to missing credentials");
2475 }
2476
2477 #[tokio::test]
2480 async fn resolve_project_dir_uses_project_dir_env() {
2481 let runner = TaskRunner::new("owner/repo".to_string());
2483
2484 let dir = tempfile::tempdir().unwrap();
2486 let dir_str = dir.path().to_string_lossy().to_string();
2487
2488 unsafe { env::set_var("PROJECT_DIR", &dir_str) };
2494 let result = runner.resolve_project_dir().await;
2495 unsafe { env::remove_var("PROJECT_DIR") };
2497
2498 assert!(
2499 result.is_ok(),
2500 "resolve_project_dir should succeed with PROJECT_DIR set"
2501 );
2502 assert_eq!(result.unwrap(), dir.path());
2503 }
2504
2505 #[tokio::test]
2506 async fn resolve_project_dir_empty_project_dir_env_falls_through() {
2507 let runner = TaskRunner::new("owner/testrepo-nonexistent".to_string());
2509 unsafe { env::set_var("PROJECT_DIR", "") };
2511 let result = runner.resolve_project_dir().await;
2512 unsafe { env::remove_var("PROJECT_DIR") };
2514
2515 assert!(
2519 result.is_err(),
2520 "resolve_project_dir should error when no project dir can be resolved"
2521 );
2522 let err = result.unwrap_err().to_string();
2523 assert!(
2524 err.contains("owner/testrepo-nonexistent"),
2525 "error should name the unresolvable repo: {err}"
2526 );
2527 }
2528
2529 struct TrackingBackend {
2532 sub_tasks_created: Arc<Mutex<Vec<(String, String)>>>, status_updates: Arc<Mutex<Vec<(String, Status)>>>,
2534 comments: Arc<Mutex<Vec<(String, String)>>>, }
2536
2537 impl TrackingBackend {
2538 fn new() -> Self {
2539 Self {
2540 sub_tasks_created: Arc::new(Mutex::new(vec![])),
2541 status_updates: Arc::new(Mutex::new(vec![])),
2542 comments: Arc::new(Mutex::new(vec![])),
2543 }
2544 }
2545 }
2546
2547 #[async_trait]
2548 impl ExternalBackend for TrackingBackend {
2549 fn name(&self) -> &'static str {
2550 "tracking"
2551 }
2552 async fn create_task(
2553 &self,
2554 _t: &str,
2555 _b: &str,
2556 _l: &[String],
2557 ) -> anyhow::Result<ExternalId> {
2558 Ok(ExternalId("new".to_string()))
2559 }
2560 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
2561 Ok(ExternalTask {
2562 id: id.clone(),
2563 title: String::new(),
2564 body: String::new(),
2565 state: "open".to_string(),
2566 labels: vec![],
2567 author: String::new(),
2568 created_at: "2026-01-01T00:00:00Z".to_string(),
2569 updated_at: "2026-01-01T00:00:00Z".to_string(),
2570 url: String::new(),
2571 })
2572 }
2573 async fn list_by_status(&self, _s: Status) -> anyhow::Result<Vec<ExternalTask>> {
2574 Ok(vec![])
2575 }
2576 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2577 Ok(vec![])
2578 }
2579 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
2580 self.comments
2581 .lock()
2582 .unwrap()
2583 .push((id.0.clone(), body.to_string()));
2584 Ok(())
2585 }
2586 async fn set_labels(&self, _id: &ExternalId, _l: &[String]) -> anyhow::Result<()> {
2587 Ok(())
2588 }
2589 async fn remove_label(&self, _id: &ExternalId, _l: &str) -> anyhow::Result<()> {
2590 Ok(())
2591 }
2592 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2593 Ok(vec![])
2594 }
2595 async fn create_sub_task(
2596 &self,
2597 parent: &ExternalId,
2598 title: &str,
2599 _body: &str,
2600 _l: &[String],
2601 ) -> anyhow::Result<ExternalId> {
2602 self.sub_tasks_created
2603 .lock()
2604 .unwrap()
2605 .push((title.to_string(), parent.0.clone()));
2606 Ok(ExternalId(format!("child-{title}")))
2607 }
2608 async fn ensure_status_label(&self, _l: &str) -> anyhow::Result<()> {
2609 Ok(())
2610 }
2611 async fn has_open_issue_with_title(&self, _t: &str, _l: &str) -> anyhow::Result<bool> {
2612 Ok(false)
2613 }
2614 async fn health_check(&self) -> anyhow::Result<()> {
2615 Ok(())
2616 }
2617 async fn is_pr_merged(&self, _b: &str) -> anyhow::Result<bool> {
2618 Ok(false)
2619 }
2620 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
2621 Ok(None)
2622 }
2623 async fn get_mentions(&self, _s: &str) -> anyhow::Result<Vec<Mention>> {
2624 Ok(vec![])
2625 }
2626 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
2627 self.status_updates
2628 .lock()
2629 .unwrap()
2630 .push((id.0.clone(), status));
2631 Ok(())
2632 }
2633 }
2634
2635 fn make_task(id: &str) -> ExternalTask {
2636 ExternalTask {
2637 id: ExternalId(id.to_string()),
2638 title: format!("Task {id}"),
2639 body: String::new(),
2640 state: "open".to_string(),
2641 labels: vec![],
2642 author: "bot".to_string(),
2643 created_at: "2026-01-01T00:00:00Z".to_string(),
2644 updated_at: "2026-01-01T00:00:00Z".to_string(),
2645 url: String::new(),
2646 }
2647 }
2648
2649 #[expect(
2652 clippy::await_holding_lock,
2653 reason = "the env lock must stay held across the awaited calls"
2654 )]
2655 #[tokio::test]
2656 async fn process_delegations_creates_subtasks_and_blocks_parent() {
2657 let _guard = ENV_LOCK.lock().unwrap();
2658 let temp_home = TempDir::new().unwrap();
2659 let orch_home = temp_home.path().join(".orch");
2660 fs::create_dir_all(&orch_home).await.unwrap();
2661 let old_orch_home = env::var("ORCH_HOME").ok();
2662 unsafe { env::set_var("ORCH_HOME", &orch_home) };
2664
2665 let runner = TaskRunner::new("owner/repo".to_string());
2666 let parent = make_task("99");
2667 let backend = Arc::new(TrackingBackend::new());
2668 let backend_dyn: Arc<dyn ExternalBackend> = Arc::<TrackingBackend>::clone(&backend);
2669
2670 let delegations = vec![
2671 Delegation {
2672 title: "Subtask A".to_string(),
2673 body: "Do A".to_string(),
2674 labels: vec!["enhancement".to_string()],
2675 },
2676 Delegation {
2677 title: "Subtask B".to_string(),
2678 body: "Do B".to_string(),
2679 labels: vec![],
2680 },
2681 ];
2682
2683 runner
2684 .process_delegations(&parent, &delegations, &backend_dyn)
2685 .await
2686 .unwrap();
2687
2688 let created = backend.sub_tasks_created.lock().unwrap();
2690 assert_eq!(created.len(), 2);
2691 assert_eq!(created[0].1, "99", "parent id should be 99");
2692 assert!(
2693 created.iter().any(|(t, _)| t == "Subtask A"),
2694 "Subtask A should be created"
2695 );
2696 assert!(
2697 created.iter().any(|(t, _)| t == "Subtask B"),
2698 "Subtask B should be created"
2699 );
2700 drop(created);
2701
2702 let updates = backend.status_updates.lock().unwrap();
2704 assert!(
2705 updates
2706 .iter()
2707 .any(|(id, s)| id == "99" && *s == Status::Blocked),
2708 "parent should be blocked"
2709 );
2710 drop(updates);
2711
2712 let comments = backend.comments.lock().unwrap();
2714 assert!(
2715 !comments.is_empty(),
2716 "a delegation summary comment should be posted"
2717 );
2718 let comment_body = &comments[0].1;
2719 assert!(
2720 comment_body.contains("Subtask A"),
2721 "summary should mention Subtask A"
2722 );
2723 assert!(
2724 comment_body.contains("Subtask B"),
2725 "summary should mention Subtask B"
2726 );
2727
2728 if let Some(old) = old_orch_home {
2729 unsafe { env::set_var("ORCH_HOME", old) };
2731 } else {
2732 unsafe { env::remove_var("ORCH_HOME") };
2734 }
2735 }
2736
2737 #[expect(
2738 clippy::await_holding_lock,
2739 reason = "the env lock must stay held across the awaited calls"
2740 )]
2741 #[tokio::test]
2742 async fn process_delegations_single_subtask() {
2743 let _guard = ENV_LOCK.lock().unwrap();
2744 let temp_home = TempDir::new().unwrap();
2745 let orch_home = temp_home.path().join(".orch");
2746 fs::create_dir_all(&orch_home).await.unwrap();
2747 let old_orch_home = env::var("ORCH_HOME").ok();
2748 unsafe { env::set_var("ORCH_HOME", &orch_home) };
2750
2751 let runner = TaskRunner::new("owner/repo".to_string());
2752 let parent = make_task("101");
2753 let backend = Arc::new(TrackingBackend::new());
2754 let backend_dyn: Arc<dyn ExternalBackend> = Arc::<TrackingBackend>::clone(&backend);
2755
2756 let delegations = vec![Delegation {
2757 title: "Only Child".to_string(),
2758 body: "Do only child".to_string(),
2759 labels: vec!["feature".to_string()],
2760 }];
2761
2762 runner
2763 .process_delegations(&parent, &delegations, &backend_dyn)
2764 .await
2765 .unwrap();
2766
2767 let created = backend.sub_tasks_created.lock().unwrap();
2768 assert_eq!(created.len(), 1);
2769 assert_eq!(created[0].0, "Only Child");
2770 drop(created);
2771
2772 let updates = backend.status_updates.lock().unwrap();
2773 assert!(
2774 updates
2775 .iter()
2776 .any(|(id, s)| id == "101" && *s == Status::Blocked),
2777 "parent should be blocked"
2778 );
2779 drop(updates);
2780
2781 let comments = backend.comments.lock().unwrap();
2782 let body = &comments[0].1;
2783 assert!(
2784 body.contains("1 subtask"),
2785 "comment should count one subtask"
2786 );
2787
2788 if let Some(old) = old_orch_home {
2789 unsafe { env::set_var("ORCH_HOME", old) };
2791 } else {
2792 unsafe { env::remove_var("ORCH_HOME") };
2794 }
2795 }
2796
2797 fn weight_signal_for(status: &str, error_type: &str, agent: &str) -> WeightSignal {
2802 let is_rerouted = status == "new" || status == "routed";
2803 let is_rate_limit_error = error_type == "rate_limit";
2804 if is_rerouted {
2805 if is_rate_limit_error {
2806 WeightSignal::RateLimited {
2807 agent: agent.to_string(),
2808 }
2809 } else {
2810 WeightSignal::Rerouted
2811 }
2812 } else if status == "done"
2813 || status == "needs_review"
2814 || status == "in_progress"
2815 || status == "in_review"
2816 {
2817 WeightSignal::Success {
2818 agent: agent.to_string(),
2819 }
2820 } else if status == "blocked" {
2821 WeightSignal::Blocked
2822 } else {
2823 WeightSignal::None
2824 }
2825 }
2826
2827 #[test]
2828 fn weight_signal_success_includes_needs_review() {
2829 let signal = weight_signal_for("needs_review", "success", "claude");
2832 assert!(
2833 matches!(signal, WeightSignal::Success { agent } if agent == "claude"),
2834 "needs_review should produce WeightSignal::Success"
2835 );
2836 }
2837
2838 #[test]
2839 fn weight_signal_success_for_all_done_statuses() {
2840 for status in ["done", "needs_review", "in_progress", "in_review"] {
2841 let signal = weight_signal_for(status, "success", "codex");
2842 assert!(
2843 matches!(signal, WeightSignal::Success { agent } if agent == "codex"),
2844 "{status} should produce WeightSignal::Success"
2845 );
2846 }
2847 }
2848
2849 #[test]
2850 fn weight_signal_rate_limited_for_reroute_with_rate_limit_error() {
2851 let signal = weight_signal_for("new", "rate_limit", "claude");
2853 assert!(matches!(signal, WeightSignal::RateLimited { agent } if agent == "claude"));
2854 }
2855
2856 #[test]
2857 fn weight_signal_rate_limited_for_routed_status_with_rate_limit_error() {
2858 let signal = weight_signal_for("routed", "rate_limit", "claude");
2859 assert!(matches!(signal, WeightSignal::RateLimited { agent } if agent == "claude"));
2860 }
2861
2862 #[test]
2863 fn weight_signal_rerouted_for_non_rate_limit_reroute() {
2864 for error_type in ["timeout", "silence detected", "parse_error", "failed"] {
2869 for status in ["new", "routed"] {
2870 let signal = weight_signal_for(status, error_type, "claude");
2871 assert!(
2872 matches!(signal, WeightSignal::Rerouted),
2873 "{status} with error_type={error_type} should produce WeightSignal::Rerouted, not RateLimited or None"
2874 );
2875 }
2876 }
2877 }
2878
2879 #[test]
2880 fn silence_reset_rerouted_never_becomes_needs_review() {
2881 let signal = weight_signal_for("new", "silence detected", "claude");
2886 assert!(
2887 matches!(signal, WeightSignal::Rerouted),
2888 "silence-reset reroute must produce Rerouted, never None/needs_review"
2889 );
2890 let display_status = match &signal {
2892 WeightSignal::Rerouted => "new",
2893 WeightSignal::None => "needs_review", _ => "other",
2895 };
2896 assert_eq!(
2897 display_status, "new",
2898 "Rerouted must map to 'new' in tick, not 'needs_review'"
2899 );
2900 }
2901
2902 #[test]
2903 fn weight_signal_blocked_status() {
2904 let signal = weight_signal_for("blocked", "success", "claude");
2905 assert!(matches!(signal, WeightSignal::Blocked));
2906 }
2907
2908 struct FailingMockRunner;
2913
2914 impl agents::AgentRunner for FailingMockRunner {
2915 #[cfg(test)]
2916 fn name(&self) -> &'static str {
2917 "opencode"
2918 }
2919
2920 fn stream_events(&self, _event: &serde_json::Value) -> Vec<agents::StreamEvent> {
2921 vec![]
2922 }
2923
2924 fn build_command(
2925 &self,
2926 _model: Option<&str>,
2927 _timeout_cmd: &str,
2928 _sys_file: &str,
2929 _msg_file: &str,
2930 _permissions: &agents::PermissionRules,
2931 ) -> String {
2932 String::new()
2933 }
2934
2935 fn parse_response(&self, raw: &str) -> Result<agents::ParsedResponse, agents::AgentError> {
2936 Err(agents::AgentError::InvalidResponse {
2938 raw: raw.chars().take(30).collect(),
2939 })
2940 }
2941
2942 fn extract_text(&self, raw: &str) -> Result<String, agents::AgentError> {
2943 Err(agents::AgentError::InvalidResponse {
2944 raw: raw.to_string(),
2945 })
2946 }
2947
2948 fn classify_error(
2949 &self,
2950 exit_code: i32,
2951 _stdout: &str,
2952 _stderr: &str,
2953 ) -> agents::AgentError {
2954 agents::AgentError::Unknown {
2955 exit_code,
2956 message: "mock error".into(),
2957 }
2958 }
2959
2960 fn router_command(&self, _prompt: &str, _model: Option<&str>) -> anyhow::Result<Command> {
2961 anyhow::bail!("not implemented for mock")
2962 }
2963 }
2964
2965 #[test]
2966 fn parse_success_output_extracts_tokens_from_raw_stdout_not_error_raw() {
2967 let ndjson = r#"{"type":"text","timestamp":1,"part":{"type":"text","text":"Here is my response: {\"status\":\"done\",\"summary\":\"fixed\"}}"}}
2969{"type":"step_finish","timestamp":2,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":95177,"input":94920,"output":257}}}"#;
2970
2971 let runner = FailingMockRunner;
2972 let result = parse_success_output("1550", "opencode", &runner, ndjson);
2973
2974 let parsed = result.expect("should synthesize response from text");
2976 assert_eq!(parsed.input_tokens, Some(94920));
2977 assert_eq!(parsed.output_tokens, Some(257));
2978 }
2979
2980 #[test]
2981 fn parse_success_output_no_tokens_when_ndjson_has_none() {
2982 let text = "No open positions, no trade executions, no conditions change.";
2985
2986 let runner = FailingMockRunner;
2987 let result = parse_success_output("1551", "opencode", &runner, text);
2988
2989 let parsed = result.expect("should synthesize response from explicit-done text");
2991 assert_eq!(parsed.input_tokens, None);
2992 assert_eq!(parsed.output_tokens, None);
2993 }
2994
2995 #[test]
2996 fn parse_success_output_uses_agent_result_directly() {
2997 let ndjson = r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"status\":\"done\",\"summary\":\"my summary\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}"#;
3000
3001 let runner = FailingMockRunner;
3003 let result = parse_success_output("999", "claude", &runner, ndjson);
3004
3005 let parsed = result.expect("should parse successfully via find_agent_result");
3006 assert_eq!(parsed.response.status, "done");
3007 assert_eq!(parsed.response.summary, "my summary");
3008 }
3009
3010 #[test]
3011 fn parse_success_output_treats_agent_result_is_error_as_authoritative() {
3012 let ndjson = r#"{"type":"result","subtype":"success","is_error":true,"result":"I failed because of reasons"}"#;
3015
3016 let runner = FailingMockRunner;
3017 let result = parse_success_output("999", "claude", &runner, ndjson);
3018
3019 let err = result.expect_err("should return error");
3020 match err {
3021 agents::AgentError::AgentFailed { message } => {
3022 assert_eq!(message, "I failed because of reasons");
3023 }
3024 _ => panic!("expected AgentFailed, got {err:?}"),
3025 }
3026 }
3027
3028 #[test]
3029 fn parse_success_output_classifies_model_unavailable_from_is_error_result() {
3030 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.)"}"#;
3037
3038 let runner = FailingMockRunner;
3039 let result = parse_success_output("999", "claude", &runner, ndjson);
3040
3041 let err = result.expect_err("should return error");
3042 match err {
3043 agents::AgentError::ModelUnavailable { model, .. } => {
3044 assert_eq!(model, "gpt-5.5");
3045 }
3046 _ => panic!("expected ModelUnavailable, got {err:?}"),
3047 }
3048 }
3049
3050 #[test]
3051 fn nonzero_exit_with_ndjson_success_is_parsed_as_success() {
3052 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}}"#;
3056
3057 let runner = FailingMockRunner;
3061
3062 let session_output = session::SessionOutput {
3064 exit_code: 1,
3065 raw_stdout: ndjson.to_string(),
3066 raw_stderr: String::new(),
3067 elapsed_secs: Some(1),
3068 sandbox_error: None,
3069 };
3070
3071 let parsed =
3074 parse_success_output("task-ndjson", "claude", &runner, &session_output.raw_stdout)
3075 .expect("should parse NDJSON success");
3076 assert_eq!(parsed.response.status, "done");
3077 assert_eq!(parsed.response.summary, "All good");
3078 assert_eq!(parsed.input_tokens, Some(10));
3079 assert_eq!(parsed.output_tokens, Some(5));
3080 }
3081
3082 struct LabelFailingBackend {
3086 inner: TrackingBackend,
3087 set_labels_err: Option<String>,
3089 }
3090
3091 impl LabelFailingBackend {
3092 fn new() -> Self {
3093 Self {
3094 inner: TrackingBackend::new(),
3095 set_labels_err: None,
3096 }
3097 }
3098 fn with_set_labels_err(mut self, err: impl Into<String>) -> Self {
3099 self.set_labels_err = Some(err.into());
3100 self
3101 }
3102 }
3103
3104 impl Default for LabelFailingBackend {
3105 fn default() -> Self {
3106 Self::new()
3107 }
3108 }
3109
3110 #[async_trait]
3111 impl ExternalBackend for LabelFailingBackend {
3112 fn name(&self) -> &'static str {
3113 "label_failing"
3114 }
3115 async fn create_task(
3116 &self,
3117 _t: &str,
3118 _b: &str,
3119 _l: &[String],
3120 ) -> anyhow::Result<ExternalId> {
3121 Ok(ExternalId("new".to_string()))
3122 }
3123 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
3124 self.inner.get_task(id).await
3125 }
3126 async fn list_by_status(&self, _s: Status) -> anyhow::Result<Vec<ExternalTask>> {
3127 Ok(vec![])
3128 }
3129 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
3130 Ok(vec![])
3131 }
3132 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
3133 self.inner.post_comment(id, body).await
3134 }
3135 async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
3136 if let Some(ref err) = self.set_labels_err {
3137 return Err(anyhow::anyhow!("{err}"));
3138 }
3139 self.inner.set_labels(id, labels).await
3140 }
3141 async fn remove_label(&self, id: &ExternalId, label: &str) -> anyhow::Result<()> {
3142 self.inner.remove_label(id, label).await
3143 }
3144 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
3145 Ok(vec![])
3146 }
3147 async fn create_sub_task(
3148 &self,
3149 parent: &ExternalId,
3150 title: &str,
3151 body: &str,
3152 labels: &[String],
3153 ) -> anyhow::Result<ExternalId> {
3154 self.inner
3155 .create_sub_task(parent, title, body, labels)
3156 .await
3157 }
3158 async fn ensure_status_label(&self, label: &str) -> anyhow::Result<()> {
3159 self.inner.ensure_status_label(label).await
3160 }
3161 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
3162 self.inner.update_status(id, status).await
3163 }
3164 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
3165 self.inner.get_authenticated_user().await
3166 }
3167 async fn get_mentions(&self, _s: &str) -> anyhow::Result<Vec<Mention>> {
3168 Ok(vec![])
3169 }
3170 async fn health_check(&self) -> anyhow::Result<()> {
3171 self.inner.health_check().await
3172 }
3173 }
3174
3175 #[tokio::test]
3180 async fn update_agent_label_returns_false_on_set_labels_failure() {
3181 let task = ExternalTask {
3182 id: ExternalId("test-reroute-1".to_string()),
3183 title: "Test".to_string(),
3184 body: "body".to_string(),
3185 state: "open".to_string(),
3186 labels: vec![],
3187 author: "bot".to_string(),
3188 created_at: "2026-01-01T00:00:00Z".to_string(),
3189 updated_at: "2026-01-01T00:00:00Z".to_string(),
3190 url: String::new(),
3191 };
3192 let backend: Arc<dyn ExternalBackend> =
3193 Arc::new(LabelFailingBackend::new().with_set_labels_err("network error"));
3194
3195 let result =
3196 update_agent_label_after_reroute("owner/repo", &task, &backend, "claude", "codex")
3197 .await;
3198
3199 assert!(
3200 !result,
3201 "update_agent_label_after_reroute should return false when set_labels fails"
3202 );
3203 }
3204
3205 #[tokio::test]
3207 async fn update_agent_label_returns_true_on_success() {
3208 let task = ExternalTask {
3209 id: ExternalId("test-reroute-2".to_string()),
3210 title: "Test".to_string(),
3211 body: "body".to_string(),
3212 state: "open".to_string(),
3213 labels: vec![],
3214 author: "bot".to_string(),
3215 created_at: "2026-01-01T00:00:00Z".to_string(),
3216 updated_at: "2026-01-01T00:00:00Z".to_string(),
3217 url: String::new(),
3218 };
3219 let backend: Arc<dyn ExternalBackend> = Arc::new(LabelFailingBackend::new());
3221
3222 let result =
3223 update_agent_label_after_reroute("owner/repo", &task, &backend, "claude", "codex")
3224 .await;
3225
3226 assert!(
3228 result,
3229 "update_agent_label_after_reroute should return true when set_labels succeeds"
3230 );
3231 }
3232
3233 #[test]
3239 fn parse_success_output_ndjson_envelope_fallback_domain_json_result() {
3240 let domain_result = r#"{"file_path":"md/journal/2026-05-08-morning-briefing.md","summary":"Morning briefing filed","health":{"status":"ok"},"priorities":[]}"#;
3241 let ndjson = format!(
3242 r#"{{"type":"result","subtype":"success","is_error":false,"terminal_reason":"completed","result":{domain_result:?},"usage":{{"input_tokens":3000,"output_tokens":400}}}}"#
3243 );
3244
3245 let runner = agents::get_runner("glm");
3246 let parsed = parse_success_output("149200", "glm", &*runner, &ndjson)
3247 .expect("should synthesize done from NDJSON envelope when result is domain JSON");
3248
3249 assert_eq!(parsed.response.status, "done");
3250 assert!(
3251 parsed.response.summary.contains("file_path") || !parsed.response.summary.is_empty(),
3252 "summary should be populated from domain JSON result text"
3253 );
3254 assert_eq!(parsed.input_tokens, Some(3000));
3255 assert_eq!(parsed.output_tokens, Some(400));
3256 }
3257
3258 #[test]
3260 fn parse_success_output_envelope_fallback_without_terminal_reason() {
3261 let receipt = r#"{"ok":true,"report":"notes.md","commit":"abc"}"#;
3262 let ndjson = format!(
3263 r#"{{"type":"result","subtype":"success","is_error":false,"stop_reason":"end_turn","result":{receipt:?},"usage":{{"input_tokens":3000,"output_tokens":400}}}}"#
3264 );
3265 let runner = agents::get_runner("grok");
3266 let parsed = parse_success_output("1", "grok", &*runner, &ndjson).expect("done");
3267 assert_eq!(parsed.response.status, "done");
3268 assert_eq!(parsed.input_tokens, Some(3000));
3269 }
3270
3271 #[test]
3275 fn parse_success_output_ndjson_envelope_fallback_plain_prose_result() {
3276 let prose = "PR #1008 now has zero diff vs main. The reverted ledger changes are gone and the branch is clean.";
3277 let ndjson = format!(
3278 r#"{{"type":"result","subtype":"success","is_error":false,"terminal_reason":"completed","result":{prose:?},"usage":{{"input_tokens":2500,"output_tokens":300}}}}"#
3279 );
3280
3281 let runner = agents::get_runner("claude");
3282 let parsed = parse_success_output("149164", "claude", &*runner, &ndjson)
3283 .expect("should synthesize done from NDJSON envelope when result is plain prose");
3284
3285 assert_eq!(parsed.response.status, "done");
3286 assert_eq!(parsed.response.summary, prose);
3287 assert_eq!(parsed.input_tokens, Some(2500));
3288 assert_eq!(parsed.output_tokens, Some(300));
3289 }
3290
3291 #[test]
3294 fn parse_success_output_ndjson_envelope_fallback_skipped_when_is_error_true() {
3295 let ndjson = r#"{"type":"result","subtype":"error","is_error":true,"result":"Something went wrong with no status"}"#;
3296
3297 let runner = agents::get_runner("claude");
3298 let err = parse_success_output("149999", "claude", &*runner, ndjson)
3299 .expect_err("is_error=true should return AgentFailed");
3300
3301 assert!(matches!(err, agents::AgentError::AgentFailed { .. }));
3302 }
3303
3304 fn tool_pair(id: &str, name: &str, cmd: &str, is_error: bool) -> String {
3305 format!(
3306 "{}\n{}\n",
3307 serde_json::json!({"type":"assistant","message":{"content":[{"type":"tool_use","id":id,"name":name,"input":{"command":cmd}}]}}),
3308 serde_json::json!({"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":id,"is_error":is_error,"content":"x"}]}}),
3309 )
3310 }
3311
3312 #[test]
3313 fn salvage_wrapup_accepts_commit_then_cleanup() {
3314 let raw = format!(
3315 "{}{}",
3316 tool_pair("1", "run_terminal_cmd", "git commit -m x", false),
3317 tool_pair("2", "run_terminal_cmd", "git status", false)
3318 );
3319 assert!(salvage_wrapup(&raw).is_some());
3320 }
3321
3322 #[test]
3323 fn salvage_wrapup_rejects_edit_after_commit_or_unanswered_call() {
3324 let edit = format!(
3325 "{}{}",
3326 tool_pair("1", "run_terminal_cmd", "git commit -m x", false),
3327 tool_pair("2", "search_replace", "", false)
3328 );
3329 assert!(salvage_wrapup(&edit).is_none());
3330 let pending = format!(
3331 "{}{}",
3332 tool_pair("1", "run_terminal_cmd", "git commit -m x", false),
3333 serde_json::json!({"type":"assistant","message":{"content":[{"type":"tool_use","id":"2","name":"run_terminal_cmd","input":{"command":"cargo test"}}]}})
3334 );
3335 assert!(salvage_wrapup(&pending).is_none());
3336 assert!(salvage_wrapup(&tool_pair("1", "run_terminal_cmd", "ls", false)).is_none());
3337 }
3338
3339 #[test]
3348 fn parse_session_output_exit0_without_envelope_is_interrupted() {
3349 let session = session::SessionOutput {
3350 exit_code: 0,
3351 raw_stdout: "\u{4}\u{4}\u{4} truncated pane junk".to_string(),
3352 raw_stderr: String::new(),
3353 elapsed_secs: Some(180),
3354 sandbox_error: None,
3355 };
3356
3357 let runner = agents::get_runner("claude");
3358 let err = parse_session_output("3876", "claude", &*runner, &session)
3359 .expect_err("missing envelope should be an error");
3360 assert!(
3361 matches!(err, agents::AgentError::Interrupted { .. }),
3362 "exit 0 with no result envelope should be Interrupted, got {err:?}"
3363 );
3364 }
3365
3366 #[test]
3367 fn parse_session_output_exit1_with_terminal_reason_completed_returns_invalid_response() {
3368 let ndjson = r#"{"type":"result","subtype":"success","is_error":false,"terminal_reason":"completed","result":"","usage":{"input_tokens":1000,"output_tokens":100}}"#;
3374 let session = session::SessionOutput {
3375 exit_code: 1,
3376 raw_stdout: ndjson.to_string(),
3377 raw_stderr: String::new(),
3378 elapsed_secs: Some(30),
3379 sandbox_error: None,
3380 };
3381
3382 let runner = agents::get_runner("claude");
3383 let result = parse_session_output("149298", "claude", &*runner, &session);
3384
3385 let err = result.expect_err("should be an error, not Ok");
3386 assert!(
3387 matches!(err, agents::AgentError::InvalidResponse { .. }),
3388 "should be InvalidResponse, got {err:?}"
3389 );
3390 }
3391
3392 #[test]
3398 fn parse_session_output_exit1_with_cost_telemetry_returns_invalid_response() {
3399 let telemetry = r#"{"type":"result","subtype":"success","is_error":false,"result":"","costUSD":0.042,"usage":{"input_tokens":5000,"output_tokens":800}}"#;
3401 let session = session::SessionOutput {
3402 exit_code: 1,
3403 raw_stdout: telemetry.to_string(),
3404 raw_stderr: String::new(),
3405 elapsed_secs: Some(120),
3406 sandbox_error: None,
3407 };
3408
3409 let runner = agents::get_runner("glm");
3410 let result = parse_session_output("149347", "glm", &*runner, &session);
3411
3412 let err = result.expect_err("should be an error, not Ok");
3413 assert!(
3414 matches!(err, agents::AgentError::InvalidResponse { .. }),
3415 "glm cost-telemetry exit-1 should return InvalidResponse, got {err:?}"
3416 );
3417 }
3418
3419 #[test]
3422 fn parse_session_output_exit1_cost_telemetry_with_is_error_true_propagates_failure() {
3423 let telemetry = r#"{"type":"result","subtype":"error","is_error":true,"result":"tool failed","costUSD":0.01,"usage":{"input_tokens":100,"output_tokens":10}}"#;
3424 let session = session::SessionOutput {
3425 exit_code: 1,
3426 raw_stdout: telemetry.to_string(),
3427 raw_stderr: String::new(),
3428 elapsed_secs: Some(15),
3429 sandbox_error: None,
3430 };
3431
3432 let runner = agents::get_runner("glm");
3433 let result = parse_session_output("149348", "glm", &*runner, &session);
3434
3435 let err = result.expect_err("should be an error");
3436 assert!(
3437 matches!(err, agents::AgentError::AgentFailed { .. }),
3438 "is_error=true with costUSD must still return AgentFailed, got {err:?}"
3439 );
3440 }
3441
3442 #[test]
3448 fn parse_session_output_exit1_preserves_error_when_is_error_true() {
3449 let ndjson = r#"{"type":"result","subtype":"error","is_error":true,"terminal_reason":"completed","result":"failed"}"#;
3450 let session = session::SessionOutput {
3451 exit_code: 1,
3452 raw_stdout: ndjson.to_string(),
3453 raw_stderr: String::new(),
3454 elapsed_secs: Some(30),
3455 sandbox_error: None,
3456 };
3457
3458 let runner = agents::get_runner("kimi");
3459 let result = parse_session_output("149299", "kimi", &*runner, &session);
3460
3461 let err = result.expect_err("should be an error");
3462 assert!(
3463 matches!(err, agents::AgentError::AgentFailed { .. }),
3464 "is_error=true should return AgentFailed, got {err:?}"
3465 );
3466 }
3467
3468 #[test]
3474 fn parse_session_output_exit1_with_rate_limit_and_terminal_reason_completed() {
3475 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}}"#;
3476 let session = session::SessionOutput {
3477 exit_code: 1,
3478 raw_stdout: ndjson.to_string(),
3479 raw_stderr: String::new(),
3480 elapsed_secs: Some(120),
3481 sandbox_error: None,
3482 };
3483
3484 let runner = agents::get_runner("claude");
3485 let result = parse_session_output("152895", "claude", &*runner, &session);
3486
3487 let err = result.expect_err("rate-limit should be an error");
3488 assert!(
3489 matches!(err, agents::AgentError::RateLimit { .. }),
3490 "session limit should return RateLimit, not downgraded, got {err:?}"
3491 );
3492 }
3493}