1pub 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#[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#[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 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 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 "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 if raw_stdout.contains("\"terminal_reason\":\"completed\"")
215 && !raw_stdout.contains("\"is_error\":true")
216 {
217 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 }
250
251 if let Some(agent_result) = agents::find_agent_result(agent_name, raw_stdout) {
253 if agent_result.is_error {
255 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 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 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 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 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 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 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
397fn 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
411pub 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 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 !matches!(err, agents::AgentError::InvalidResponse { .. }) {
442 return Err(err);
443 }
444 if session_output.exit_code != 0 {
445 let combined = &combined_output;
456 let is_completed_signal =
457 combined.contains("\"terminal_reason\":\"completed\"");
458 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
498pub struct TaskRunner {
500 repo: String,
502 orch_home: PathBuf,
504 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 pub fn with_store(mut self, store: Arc<crate::store::TaskStore>) -> Self {
524 self.store = Some(store);
525 self
526 }
527
528 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 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 String::new()
590 } else {
591 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 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 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 if backend.is_some() {
659 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 let project_dir = self.resolve_project_dir().await?;
696
697 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 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 store::store_touch_updated_at(&self.store, &self.repo, task_id).await;
725
726 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 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 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 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 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 session::cleanup_session(task_id, &tmux, &tmux_session).await;
868 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 session::cleanup_session(task_id, &tmux, &tmux_session).await;
903
904 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 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 #[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 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 let db_error_type: Option<String> = if outcome == "success" {
978 None
979 } else {
980 Some(outcome.to_string())
981 };
982
983 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 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 let started_at = Utc::now();
1056
1057 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 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 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 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(_) => {} 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 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 let is_rerouted = status == "new" || status == "routed";
1228 if is_rerouted {
1229 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 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 WeightSignal::Rerouted
1276 }
1277 } else if status == "needs_review" {
1278 let has_rate_limit_error = classify_run_error_type(&last_error) == "rate_limit";
1290 if has_rate_limit_error {
1291 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 crate::engine::cooldown::record_rate_limit(&agent_name, model, &last_error).await;
1304 WeightSignal::RateLimited {
1305 agent: agent_name.clone(),
1306 }
1307 } else {
1308 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 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 {
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 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 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 if is_internal_id(task_id) {
1401 return Ok(weight_signal);
1402 }
1403
1404 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, "new" => Status::New, _ => Status::NeedsReview,
1414 };
1415 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 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 let footer = crate::engine::attribution_footer("Created", &agent_name, model);
1448 raw_comment.push_str(&footer);
1449
1450 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 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 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 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 let mut labels = delegation.labels.clone();
1506 labels.push("status:new".to_string());
1507
1508 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 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 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 async fn resolve_project_dir(&self) -> anyhow::Result<PathBuf> {
1581 if let Ok(dir) = std::env::var("PROJECT_DIR") {
1583 if !dir.is_empty() {
1584 return Ok(PathBuf::from(dir));
1585 }
1586 }
1587
1588 if let Ok(paths) = config::get_project_paths() {
1590 for path_str in &paths {
1591 let path = PathBuf::from(path_str);
1592 if let Ok(repo) = config::get_repo_for_project(&path) {
1594 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 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 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 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
1650async 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 #[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 #[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"; 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 let s = "日本語";
1775 assert_eq!(s.len(), 9);
1776 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"; 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"; assert_eq!(safe_utf8_tail(s, 6), "abcdef"); assert_eq!(safe_utf8_tail(s, 0), ""); }
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(audit.error, "");
2149 }
2150
2151 #[tokio::test]
2152 async fn build_run_audit_non_canonical_status_reports_explicit_error() {
2153 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 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 #[tokio::test]
2249 async fn resolve_project_dir_uses_project_dir_env() {
2250 let runner = TaskRunner::new("owner/repo".to_string());
2252
2253 let dir = tempfile::tempdir().unwrap();
2255 let dir_str = dir.path().to_string_lossy().to_string();
2256
2257 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 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 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 struct TrackingBackend {
2297 sub_tasks_created: Arc<Mutex<Vec<(String, String)>>>, status_updates: Arc<Mutex<Vec<(String, Status)>>>,
2299 comments: Arc<Mutex<Vec<(String, String)>>>, }
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 #[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 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 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 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 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 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 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 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 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 let display_status = match &signal {
2645 WeightSignal::Rerouted => "new",
2646 WeightSignal::None => "needs_review", _ => "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 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 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 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 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 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 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 let ndjson = r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"status\":\"done\",\"summary\":\"my summary\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}"#;
2757
2758 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 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 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 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 let runner = FailingMockRunner;
2818
2819 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 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 struct LabelFailingBackend {
2842 inner: TrackingBackend,
2843 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 #[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 #[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 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 assert!(
2985 result,
2986 "update_agent_label_after_reroute should return true when set_labels succeeds"
2987 );
2988 }
2989
2990 #[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 #[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 #[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 #[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 #[test]
3070 fn parse_session_output_exit1_with_terminal_reason_completed_returns_invalid_response() {
3071 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 #[test]
3100 fn parse_session_output_exit1_with_cost_telemetry_returns_invalid_response() {
3101 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 #[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 #[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 #[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}