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 && raw_stdout.contains("\"terminal_reason\":\"completed\"")
340 && !agent_result.result_text.is_empty()
341 {
342 let summary_end =
343 agents::truncate_at_char_boundary(&agent_result.result_text, 500);
344 let summary = agent_result.result_text[..summary_end].to_string();
345 tracing::warn!(
346 task_id,
347 agent = agent_name,
348 "NDJSON envelope indicates success but result lacks AgentResponse \
349 schema — synthesizing done response"
350 );
351 return Ok(agents::ParsedResponse {
352 response: crate::parser::AgentResponse {
353 status: "done".to_string(),
354 summary,
355 ..Default::default()
356 },
357 input_tokens: agent_result.input_tokens,
358 output_tokens: agent_result.output_tokens,
359 duration_ms: agent_result.duration_ms,
360 });
361 }
362 }
363 }
364 }
365
366 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
397pub fn parse_session_output(
406 task_id: &str,
407 agent_name: &str,
408 agent_runner: &dyn agents::AgentRunner,
409 session_output: &session::SessionOutput,
410) -> Result<agents::ParsedResponse, agents::AgentError> {
411 let combined_output = format!("{}{}", session_output.raw_stdout, session_output.raw_stderr);
416 if !(session_output.raw_stdout.is_empty() && session_output.raw_stderr.is_empty()) {
417 match parse_success_output(task_id, agent_name, agent_runner, &combined_output) {
418 Ok(parsed) => return Ok(parsed),
419 Err(err) => {
420 if !matches!(err, agents::AgentError::InvalidResponse { .. }) {
428 return Err(err);
429 }
430 if session_output.exit_code != 0 {
431 let combined = &combined_output;
442 let is_completed_signal =
443 combined.contains("\"terminal_reason\":\"completed\"");
444 let is_cost_telemetry_only = combined.contains("\"costUSD\":")
450 && !combined.contains("\"is_error\":true");
451 if is_completed_signal || is_cost_telemetry_only {
452 let raw_tail = agents::patterns::safe_tail(combined, 300);
453 return Err(agents::AgentError::InvalidResponse {
454 raw: raw_tail.to_string(),
455 });
456 }
457 return Err(agent_runner.classify_error_with_elapsed(
458 session_output.exit_code,
459 &session_output.raw_stdout,
460 &session_output.raw_stderr,
461 session_output.elapsed_secs,
462 ));
463 }
464 return Err(err);
465 }
466 }
467 }
468
469 if session_output.exit_code != 0 {
470 return Err(agent_runner.classify_error_with_elapsed(
471 session_output.exit_code,
472 &session_output.raw_stdout,
473 &session_output.raw_stderr,
474 session_output.elapsed_secs,
475 ));
476 }
477
478 Err(agents::AgentError::Unknown {
479 exit_code: session_output.exit_code,
480 message: "empty-output-exit0: opencode returned exit 0 with empty stdout".to_string(),
481 })
482}
483
484pub struct TaskRunner {
486 repo: String,
488 orch_home: PathBuf,
490 store: Option<Arc<crate::store::TaskStore>>,
492}
493
494impl TaskRunner {
495 pub fn new(repo: String) -> Self {
496 let orch_home = crate::home::orch_home().unwrap_or_else(|e| {
497 tracing::warn!(error = %e, "orch_home() failed — falling back to /tmp/.orch");
498 PathBuf::from("/tmp").join(".orch")
499 });
500
501 Self {
502 repo,
503 orch_home,
504 store: None,
505 }
506 }
507
508 pub fn with_store(mut self, store: Arc<crate::store::TaskStore>) -> Self {
510 self.store = Some(store);
511 self
512 }
513
514 async fn get_field(&self, task_id: &str, field: &str) -> Option<String> {
516 let task = store::opt_store_get_task(&self.store, &self.repo, task_id).await?;
517 match field {
518 "route_reason" => Some(task.route_reason),
519 "worktree" => Some(task.worktree),
520 "attempts" => Some(task.attempts.to_string()),
521 "delegations" => serde_json::to_string(&task.delegations).ok(),
522 "summary" => Some(task.summary),
523 "last_error" => Some(task.last_error),
524 "agent" => task.agent,
525 _ => None,
526 }
527 }
528
529 async fn build_run_audit(&self, input: RunAuditInput<'_>) -> RunAudit {
530 let last_error = self
536 .get_field(input.task_id, "last_error")
537 .await
538 .filter(|s| !s.is_empty());
539 let outcome =
540 classify_run_outcome(input.status, input.parse_result, input.push_failed).to_string();
541
542 let error = if outcome == "success" {
543 String::new()
544 } else {
545 let error = match input.error_override.as_deref().filter(|s| !s.is_empty()) {
546 Some(error_override) => error_override.to_string(),
547 None => match last_error {
548 Some(last_error) => last_error,
549 None => match input.parse_result {
550 Ok(parsed) => parsed.response.error.clone().unwrap_or_default(),
551 Err(err) => err.to_string(),
552 },
553 },
554 };
555
556 if error.is_empty() {
557 if input.status == "blocked" {
558 match input.parse_result {
559 Ok(parsed) if !parsed.response.summary.is_empty() => {
560 parsed.response.summary.clone()
561 }
562 _ => "task blocked without explicit error".to_string(),
563 }
564 } else if matches!(
565 input.status,
566 "done"
567 | "completed"
568 | "in_progress"
569 | "in_review"
570 | "needs_review"
571 | "routed"
572 | "new"
573 ) {
574 String::new()
576 } else {
577 format!("unrecognized status: {}", input.status)
581 }
582 } else {
583 error
584 }
585 };
586
587 let total_cost_usd = match &self.store {
588 Some(_) => {
589 store::get_cost_estimate(&self.store, &self.repo, input.task_id)
590 .await
591 .total_cost_usd
592 }
593 None => 0.0,
594 };
595
596 let (input_tokens, output_tokens) = extract_run_tokens(input.parse_result);
597
598 let duration_secs = input
599 .elapsed_secs
600 .map(|s| s as f64)
601 .unwrap_or_else(|| run_duration_secs(input.started_at));
602
603 RunAudit {
604 stdout: input.raw_stdout.to_string(),
605 stderr: input.raw_stderr.to_string(),
606 parsed_response: serialize_parsed_response(input.parse_result, input.raw_stdout),
607 outcome,
608 error,
609 input_tokens,
610 output_tokens,
611 total_cost_usd,
612 duration_secs,
613 }
614 }
615
616 pub async fn run(
621 &self,
622 task_id: &str,
623 agent: Option<&str>,
624 model: Option<&str>,
625 backend: Option<&dyn ExternalBackend>,
626 started_at: &chrono::DateTime<Utc>,
627 ) -> anyhow::Result<Option<RunExecution>> {
628 tracing::info!(
629 task_id,
630 agent = agent.unwrap_or("default"),
631 model = model.unwrap_or("default"),
632 "starting task execution"
633 );
634
635 let attempts = match task_init::check_guards(task_id, &self.repo, &self.store).await {
637 Ok(task_init::GuardOutcome::Proceed(a)) => a,
638 Ok(task_init::GuardOutcome::Skip) => {
639 return Ok(None);
640 }
641 Ok(task_init::GuardOutcome::MaxAttempts) => {
642 if let Some(b) = backend {
644 let id = crate::backends::ExternalId(task_id.to_string());
645 if let Err(e) = b
646 .update_status(&id, crate::backends::Status::NeedsReview)
647 .await
648 {
649 tracing::warn!(task_id, err = %e, "failed to update backend status to NeedsReview after max attempts");
650 }
651
652 let route_reason = self
655 .get_field(task_id, "route_reason")
656 .await
657 .unwrap_or_default();
658 if route_reason.starts_with("label agent:") {
659 let agent_label = route_reason.trim_start_matches("label ");
660 let gh = crate::github::http::GhHttp::new()?;
661 if let Err(e) = gh.remove_label(&self.repo, task_id, agent_label).await {
662 tracing::warn!(task_id, label = agent_label, err = %e, "failed to remove agent label after max attempts");
663 } else {
664 tracing::info!(task_id, label = agent_label, "removed forced agent label after max attempts — /retry will use free routing");
665 }
666 }
667 }
668 return Ok(Some(RunExecution {
669 status: "needs_review".to_string(),
670 exit_code: None,
671 audit: RunAudit {
672 stdout: String::new(),
673 stderr: String::new(),
674 parsed_response: String::new(),
675 outcome: "failed".to_string(),
676 error: "max attempts reached".to_string(),
677 input_tokens: 0,
678 output_tokens: 0,
679 total_cost_usd: 0.0,
680 duration_secs: 0.0,
681 },
682 }));
683 }
684 Err(e) => return Err(e),
685 };
686
687 let project_dir = self.resolve_project_dir().await?;
689
690 let init = task_init::prepare_task(
692 task_id,
693 agent,
694 model,
695 backend,
696 &self.repo,
697 &project_dir,
698 attempts,
699 &self.store,
700 )
701 .await?;
702
703 let (tmux, tmux_session, session_output) = session::run_agent_session(
705 task_id,
706 &init.invocation,
707 &init.attempt_dir,
708 &self.orch_home,
709 )
710 .await;
711
712 store::store_touch_updated_at(&self.store, &self.repo, task_id).await;
718
719 if let Some(stored) = store::opt_store_get_task(&self.store, &self.repo, task_id).await {
727 let silence_reset = matches!(
728 stored.status,
729 crate::store::TaskStatus::New
730 | crate::store::TaskStatus::Routed
731 | crate::store::TaskStatus::NeedsReview
732 );
733 if silence_reset {
734 let status_str = stored.status.as_str();
735 tracing::info!(
736 task_id,
737 status = status_str,
738 "task already reset by silence detection — skipping fallback processing"
739 );
740 session::cleanup_session(task_id, &tmux, &tmux_session).await;
741 let silence_err = agents::patterns::classify_from_text(0, "silence detected");
742 let silence_parse: Result<agents::ParsedResponse, agents::AgentError> =
743 Err(silence_err);
744 let audit = self
745 .build_run_audit(RunAuditInput {
746 task_id,
747 status: status_str,
748 parse_result: &silence_parse,
749 raw_stdout: &session_output.raw_stdout,
750 raw_stderr: &session_output.raw_stderr,
751 started_at,
752 error_override: Some(format!("silence detection set task to {status_str}")),
753 elapsed_secs: session_output.elapsed_secs,
754 push_failed: false,
755 })
756 .await;
757 return Ok(Some(RunExecution {
758 status: status_str.to_string(),
759 exit_code: Some(session_output.exit_code),
760 audit,
761 }));
762 }
763 }
764
765 let stdout_len = session_output.raw_stdout.len();
767 let stderr_len = session_output.raw_stderr.len();
768 let stdout_tail: String = session_output
769 .raw_stdout
770 .chars()
771 .rev()
772 .take(500)
773 .collect::<String>()
774 .chars()
775 .rev()
776 .collect();
777 let stderr_tail: String = session_output
778 .raw_stderr
779 .chars()
780 .rev()
781 .take(500)
782 .collect::<String>()
783 .chars()
784 .rev()
785 .collect();
786 tracing::info!(
787 task_id,
788 exit_code = session_output.exit_code,
789 stdout_len,
790 stderr_len,
791 stdout_tail = %stdout_tail,
792 stderr_tail = %stderr_tail,
793 "agent raw output"
794 );
795
796 let agent_runner = agents::get_runner(&init.agent_name);
804 let parse_result =
805 parse_session_output(task_id, &init.agent_name, &*agent_runner, &session_output);
806
807 response_handler::write_result_json(
809 &init.attempt_dir,
810 task_id,
811 &init.agent_name,
812 init.model_name.as_deref(),
813 session_output.exit_code,
814 init.new_attempts,
815 &parse_result,
816 &session_output.raw_stdout,
817 &session_output.raw_stderr,
818 )
819 .await;
820
821 let (final_status, push_failed, fallback_error) = match parse_result {
828 Ok(ref parsed) => {
829 let (status, push_failed) = response_handler::handle_success(
830 task_id,
831 parsed.clone(),
832 &init.wt,
833 &init.task_title,
834 &init.agent_name,
835 init.model_name.as_deref(),
836 init.new_attempts,
837 &self.repo,
838 &self.store,
839 &session_output.raw_stdout,
840 )
841 .await?;
842 (status, push_failed, None)
843 }
844 Err(ref agent_err) => {
845 match fallback::handle_error(
846 task_id,
847 agent_err,
848 &init.agent_name,
849 &*agent_runner,
850 init.model_name.as_deref(),
851 init.complexity.as_deref(),
852 init.new_attempts,
853 &self.store,
854 &self.repo,
855 )
856 .await?
857 {
858 fallback::ErrorHandleResult::EarlyReturn { status } => {
859 session::cleanup_session(task_id, &tmux, &tmux_session).await;
861 let audit = self
869 .build_run_audit(RunAuditInput {
870 task_id,
871 status: &status,
872 parse_result: &parse_result,
873 raw_stdout: &session_output.raw_stdout,
874 raw_stderr: &session_output.raw_stderr,
875 started_at,
876 error_override: None,
877 elapsed_secs: session_output.elapsed_secs,
878 push_failed: false,
879 })
880 .await;
881 return Ok(Some(RunExecution {
882 status,
883 exit_code: Some(session_output.exit_code),
884 audit,
885 }));
886 }
887 fallback::ErrorHandleResult::Continue { status, error } => {
888 (status, false, Some(error))
889 }
890 }
891 }
892 };
893
894 session::cleanup_session(task_id, &tmux, &tmux_session).await;
896
897 if final_status.as_str() == "done" {
901 if let Some(ref st) = self.store {
902 if let Err(e) =
903 crate::engine::cleanup::cleanup_task_worktree(task_id, &self.repo, st).await
904 {
905 tracing::warn!(task_id, error = ?e, "worktree cleanup failed after no-op done");
906 }
907 }
908 }
909
910 let audit = self
915 .build_run_audit(RunAuditInput {
916 task_id,
917 status: &final_status,
918 parse_result: &parse_result,
919 raw_stdout: &session_output.raw_stdout,
920 raw_stderr: &session_output.raw_stderr,
921 started_at,
922 error_override: fallback_error,
923 elapsed_secs: session_output.elapsed_secs,
924 push_failed,
925 })
926 .await;
927
928 Ok(Some(RunExecution {
929 status: final_status.to_string(),
930 exit_code: Some(session_output.exit_code),
931 audit,
932 }))
933 }
934
935 #[allow(clippy::too_many_arguments)]
937 async fn record_metrics(
938 &self,
939 task_id: &str,
940 agent_name: &str,
941 model_name: &Option<String>,
942 route_result: &Option<RouteResult>,
943 started_at: &chrono::DateTime<Utc>,
944 attempts: u32,
945 final_status: &str,
946 error_type: Option<&str>,
947 ) {
948 let completed_at = Utc::now();
949 let duration_seconds = (completed_at - *started_at).num_milliseconds() as f64 / 1000.0;
950
951 let outcome = match final_status {
953 "done" | "in_progress" | "in_review" => "success",
954 "new" => "rerouted",
955 "needs_review" => classify_run_error_type(error_type.unwrap_or("")),
956 _ => "unknown",
957 };
958
959 let complexity = route_result.as_ref().map(|r| r.complexity.clone());
960 let files_changed = git_ops::count_changed_files(&PathBuf::from(
961 self.get_field(task_id, "worktree")
962 .await
963 .unwrap_or_default(),
964 ))
965 .await
966 .unwrap_or(0);
967
968 if let Some(ref store) = self.store {
969 let db_error_type: Option<String> = if outcome == "success" {
971 None
972 } else {
973 Some(outcome.to_string())
974 };
975
976 let usage = store::get_token_usage(&self.store, &self.repo, task_id).await;
978 let cost = store::get_cost_estimate(&self.store, &self.repo, task_id).await;
979 let input_tokens = if usage.input_tokens > 0 {
980 Some(usage.input_tokens as i64)
981 } else {
982 None
983 };
984 let output_tokens = if usage.output_tokens > 0 {
985 Some(usage.output_tokens as i64)
986 } else {
987 None
988 };
989 let input_cost = if cost.input_cost_usd > 0.0 {
990 Some(cost.input_cost_usd)
991 } else {
992 None
993 };
994 let output_cost = if cost.output_cost_usd > 0.0 {
995 Some(cost.output_cost_usd)
996 } else {
997 None
998 };
999 let total_cost = if cost.total_cost_usd > 0.0 {
1000 Some(cost.total_cost_usd)
1001 } else {
1002 None
1003 };
1004
1005 let metric = InsertTaskMetric {
1006 repo: &self.repo,
1007 task_id,
1008 agent: agent_name,
1009 model: model_name.as_deref(),
1010 complexity: complexity.as_deref(),
1011 outcome,
1012 duration_seconds,
1013 started_at,
1014 completed_at: &completed_at,
1015 attempts: attempts as i32 + 1,
1016 files_changed: files_changed as i32,
1017 error_type: db_error_type.as_deref(),
1018 input_tokens,
1019 output_tokens,
1020 input_cost_usd: input_cost,
1021 output_cost_usd: output_cost,
1022 total_cost_usd: total_cost,
1023 };
1024
1025 if let Err(e) = store.insert_task_metric(&metric).await {
1026 tracing::error!(task_id, ?e, "failed to record task metrics");
1027 }
1028 }
1029 }
1030
1031 pub async fn run_with_context(
1036 &self,
1037 task: &ExternalTask,
1038 backend: &Arc<dyn ExternalBackend>,
1039 _tmux: &Arc<TmuxManager>,
1040 route_result: Option<&RouteResult>,
1041 ) -> anyhow::Result<WeightSignal> {
1042 let task_id = &task.id.0;
1043 let agent = route_result.map(|r| r.agent.as_str());
1044 let agent_name = agent.unwrap_or("claude").to_string();
1045 let model = route_result.and_then(|r| r.model.as_deref());
1046
1047 let started_at = Utc::now();
1049
1050 let run_audit_id = if let Some(ref store) = self.store {
1054 let attempt: i32 = self
1055 .get_field(task_id, "attempts")
1056 .await
1057 .and_then(|s| s.parse().ok())
1058 .unwrap_or(0)
1059 + 1;
1060 if let Ok(Some(store_id)) = store.resolve_task_id(&self.repo, task_id).await {
1061 match store
1062 .start_run(&crate::store::StartRun {
1063 task_id: store_id,
1064 attempt,
1065 run_type: "agent",
1066 agent: &agent_name,
1067 model: model.unwrap_or(""),
1068 command: &format!("{} --model {}", agent_name, model.unwrap_or("default")),
1069 prompt: &task.body,
1070 })
1071 .await
1072 {
1073 Ok(run_id) => Some(run_id),
1074 Err(e) => {
1075 tracing::warn!(
1076 task_id,
1077 error = %e,
1078 "failed to record run start in audit trail"
1079 );
1080 None
1081 }
1082 }
1083 } else {
1084 None
1085 }
1086 } else {
1087 None
1088 };
1089
1090 let run_result = match self
1093 .run(task_id, agent, model, Some(&**backend), &started_at)
1094 .await
1095 {
1096 Ok(run_result) => run_result,
1097 Err(e) => {
1098 if let Some(run_id) = run_audit_id {
1099 if let Some(ref store) = self.store {
1100 if let Err(complete_err) = store
1101 .complete_run(&crate::store::CompleteRun {
1102 run_id,
1103 exit_code: Some(-1),
1104 stdout: "",
1105 stderr: "",
1106 parsed: "",
1107 outcome: "failed",
1108 error: &e.to_string(),
1109 tokens: crate::store::RunTokenUsage::default(),
1110 })
1111 .await
1112 {
1113 tracing::warn!(
1114 task_id,
1115 run_id,
1116 error = %complete_err,
1117 "failed to record run completion in audit trail after runner error"
1118 );
1119 }
1120 }
1121 }
1122 return Err(e);
1123 }
1124 };
1125
1126 let (status, exit_code_opt, run_audit) = match run_result {
1128 Some(result) => (result.status, result.exit_code, result.audit),
1129 None => {
1130 if let Some(run_id) = run_audit_id {
1131 if let Some(ref store) = self.store {
1132 if let Err(e) = store
1133 .complete_run(&crate::store::CompleteRun {
1134 run_id,
1135 exit_code: Some(-1),
1136 stdout: "",
1137 stderr: "",
1138 parsed: "",
1139 outcome: "aborted",
1140 error: "run skipped by guard",
1141 tokens: crate::store::RunTokenUsage::default(),
1142 })
1143 .await
1144 {
1145 tracing::warn!(
1146 task_id,
1147 run_id,
1148 error = %e,
1149 "failed to record skipped run completion in audit trail"
1150 );
1151 }
1152 }
1153 }
1154 tracing::info!(task_id, "guard skipped task — not posting stale result");
1155 return Ok(WeightSignal::None);
1156 }
1157 };
1158
1159 let delegations_raw = self
1161 .get_field(task_id, "delegations")
1162 .await
1163 .unwrap_or_default();
1164 if !delegations_raw.is_empty() {
1165 match serde_json::from_str::<Vec<crate::parser::Delegation>>(&delegations_raw) {
1166 Ok(delegations) if !delegations.is_empty() => {
1167 self.process_delegations(task, &delegations, backend)
1168 .await?;
1169 if let Err(e) = store::store_set_result(
1170 &self.store,
1171 &self.repo,
1172 task_id,
1173 &[("delegations", serde_json::json!([]))],
1174 )
1175 .await
1176 {
1177 tracing::warn!(task_id, err = %e, "failed to clear delegations in store");
1178 }
1179 }
1180 Ok(_) => {} Err(e) => {
1182 tracing::error!(task_id, error = %e, "corrupt delegations JSON — clearing");
1183 if let Err(e2) = store::store_set_result(
1184 &self.store,
1185 &self.repo,
1186 task_id,
1187 &[("delegations", serde_json::json!([]))],
1188 )
1189 .await
1190 {
1191 tracing::warn!(task_id, err = %e2, "failed to clear corrupt delegations in store");
1192 }
1193 if let Err(e2) = store::store_set_result(
1194 &self.store,
1195 &self.repo,
1196 task_id,
1197 &[(
1198 "last_error",
1199 serde_json::json!(format!("delegation parse failed: {e}")),
1200 )],
1201 )
1202 .await
1203 {
1204 tracing::warn!(task_id, err = %e2, "failed to write delegation_parse_failed last_error to store");
1205 }
1206 }
1207 }
1208 }
1209
1210 let summary = self.get_field(task_id, "summary").await.unwrap_or_default();
1212 let last_error = self
1213 .get_field(task_id, "last_error")
1214 .await
1215 .unwrap_or_default();
1216
1217 let is_rerouted = status == "new" || status == "routed";
1221 if is_rerouted {
1222 let silence_already_reset = store::opt_store_get_task(&self.store, &self.repo, task_id)
1228 .await
1229 .map(|t| !matches!(t.status, crate::store::TaskStatus::InProgress))
1230 .unwrap_or(false);
1231
1232 if !silence_already_reset {
1233 if let Err(e) = store::store_set_result(
1234 &self.store,
1235 &self.repo,
1236 task_id,
1237 &[
1238 ("agent", serde_json::json!("")),
1239 ("model", serde_json::json!("")),
1240 ],
1241 )
1242 .await
1243 {
1244 tracing::warn!(task_id, err = %e, "failed to clear agent/model for reroute in store");
1245 }
1246 } else {
1247 tracing::debug!(
1248 task_id,
1249 "silence detection already set fallback agent — skipping agent/model clear"
1250 );
1251 }
1252 }
1253 let is_rate_limit_error = classify_run_error_type(&last_error) == "rate_limit";
1258 let weight_signal = if is_rerouted {
1259 if is_rate_limit_error {
1260 WeightSignal::RateLimited {
1261 agent: agent_name.clone(),
1262 }
1263 } else {
1264 WeightSignal::Rerouted
1269 }
1270 } else if status == "needs_review" {
1271 let has_rate_limit_error = classify_run_error_type(&last_error) == "rate_limit";
1283 if has_rate_limit_error {
1284 if let Some(ref store) = self.store {
1286 if let Err(e) = store
1287 .record_rate_limit(&agent_name, "rate_limit", Some(task_id))
1288 .await
1289 {
1290 tracing::warn!(task_id, agent = %agent_name, err = %e, "failed to persist rate_limit event — router health check may miss this signal");
1291 }
1292 }
1293 crate::engine::cooldown::record_rate_limit(&agent_name, model, &last_error).await;
1297 WeightSignal::RateLimited {
1298 agent: agent_name.clone(),
1299 }
1300 } else {
1301 crate::engine::cooldown::record_agent_success(&agent_name, model.unwrap_or(""))
1304 .await;
1305 WeightSignal::Success {
1306 agent: agent_name.clone(),
1307 }
1308 }
1309 } else if status == "done" || status == "in_progress" || status == "in_review" {
1310 crate::engine::cooldown::record_agent_success(&agent_name, model.unwrap_or("")).await;
1312 WeightSignal::Success {
1313 agent: agent_name.clone(),
1314 }
1315 } else if status == "blocked" {
1316 WeightSignal::Blocked
1317 } else {
1318 WeightSignal::None
1319 };
1320
1321 {
1323 let attempts: u32 = self
1324 .get_field(task_id, "attempts")
1325 .await
1326 .and_then(|s| s.parse().ok())
1327 .unwrap_or(0);
1328 self.record_metrics(
1329 task_id,
1330 &agent_name,
1331 &model.map(|s| s.to_string()),
1332 &route_result.cloned(),
1333 &started_at,
1334 attempts,
1335 &status,
1336 if last_error.is_empty() {
1337 None
1338 } else {
1339 Some(&last_error)
1340 },
1341 )
1342 .await;
1343 }
1344
1345 if let Some(run_id) = run_audit_id {
1347 if let Some(ref store) = self.store {
1348 if let Err(e) = store
1349 .complete_run(&crate::store::CompleteRun {
1350 run_id,
1351 exit_code: exit_code_opt,
1352 stdout: &run_audit.stdout,
1353 stderr: &run_audit.stderr,
1354 parsed: &run_audit.parsed_response,
1355 outcome: &run_audit.outcome,
1356 error: &run_audit.error,
1357 tokens: crate::store::RunTokenUsage {
1358 input_tokens: run_audit.input_tokens,
1359 output_tokens: run_audit.output_tokens,
1360 total_cost_usd: run_audit.total_cost_usd,
1361 duration_secs: run_audit.duration_secs,
1362 },
1363 })
1364 .await
1365 {
1366 tracing::warn!(
1367 task_id,
1368 run_id,
1369 error = %e,
1370 "failed to record run completion in audit trail"
1371 );
1372 }
1373 }
1374 }
1375
1376 if is_rerouted && !is_internal_id(task_id) {
1379 let new_agent = self.get_field(task_id, "agent").await.unwrap_or_default();
1380 if !new_agent.is_empty() && new_agent != agent_name {
1381 update_agent_label_after_reroute(
1382 &self.repo,
1383 task,
1384 backend,
1385 &agent_name,
1386 &new_agent,
1387 )
1388 .await;
1389 }
1390 }
1391
1392 if is_internal_id(task_id) {
1394 return Ok(weight_signal);
1395 }
1396
1397 let new_status = match status.as_str() {
1399 "done" => Status::Done,
1400 "in_progress" => Status::InProgress,
1401 "in_review" => Status::InReview,
1402 "blocked" => Status::Blocked,
1403 "needs_review" => Status::NeedsReview,
1404 "routed" => Status::Routed, "new" => Status::New, _ => Status::NeedsReview,
1407 };
1408 if let Some(ref store) = self.store {
1410 if let Ok(Some(store_id)) = store.resolve_task_id(&self.repo, &task.id.0).await {
1411 store
1412 .update_status(
1413 store_id,
1414 crate::engine::tasks::status_to_task_status(new_status),
1415 )
1416 .await
1417 .context("store-first status update failed in runner")?;
1418 }
1419 }
1420 if let Err(e) = backend.update_status(&task.id, new_status).await {
1421 tracing::warn!(
1422 task_id,
1423 ?new_status,
1424 err = %e,
1425 "failed to mirror runner status to backend — store is authoritative"
1426 );
1427 }
1428
1429 let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
1431 let mut raw_comment = if !summary.is_empty() {
1432 format!("[{now}] {status}: {summary}")
1433 } else if !last_error.is_empty() {
1434 format!("[{now}] {status}: {last_error}")
1435 } else {
1436 format!("[{now}] {status}")
1437 };
1438
1439 let footer = crate::engine::attribution_footer("Created", &agent_name, model);
1441 raw_comment.push_str(&footer);
1442
1443 let comment = if security::has_leaks(&raw_comment) {
1445 let leaks = security::scan(&raw_comment);
1446 let rules: Vec<&str> = leaks.iter().map(|l| l.rule).collect();
1447 let warning = format!(
1448 "\n\n> ⚠️ **Security Notice**: {} potential secret(s) detected and redacted: {}",
1449 leaks.len(),
1450 rules.join(", ")
1451 );
1452 let redacted = security::redact(&raw_comment);
1453 format!("{redacted}{warning}")
1454 } else {
1455 raw_comment
1456 };
1457 backend.post_comment(&task.id, &comment).await?;
1458
1459 Ok(weight_signal)
1460 }
1461
1462 async fn process_delegations(
1468 &self,
1469 parent_task: &ExternalTask,
1470 delegations: &[crate::parser::Delegation],
1471 backend: &Arc<dyn ExternalBackend>,
1472 ) -> anyhow::Result<()> {
1473 let parent_id = &parent_task.id;
1474
1475 let existing_titles: std::collections::HashSet<String> =
1478 match backend.list_all_tasks().await {
1479 Ok(tasks) => tasks.into_iter().map(|t| t.title).collect(),
1480 Err(e) => {
1481 tracing::warn!(err = %e, "failed to fetch tasks for dedup — will create all");
1482 std::collections::HashSet::new()
1483 }
1484 };
1485
1486 for delegation in delegations {
1487 if existing_titles.contains(&delegation.title) {
1489 tracing::info!(
1490 parent = parent_id.0,
1491 title = delegation.title,
1492 "skipping delegation — issue with same title already exists"
1493 );
1494 continue;
1495 }
1496
1497 let mut labels = delegation.labels.clone();
1499 labels.push("status:new".to_string());
1500
1501 let child_body = format!(
1503 "{}\n\n---\n_Delegated from #{}_{}",
1504 delegation.body,
1505 parent_id.0,
1506 crate::engine::orch_footer()
1507 );
1508
1509 match backend
1510 .create_sub_task(parent_id, &delegation.title, &child_body, &labels)
1511 .await
1512 {
1513 Ok(child_id) => {
1514 tracing::info!(
1515 parent = parent_id.0,
1516 child = child_id.0,
1517 title = delegation.title,
1518 "created delegated subtask"
1519 );
1520 }
1521 Err(e) => {
1522 tracing::error!(
1523 parent = parent_id.0,
1524 title = delegation.title,
1525 err = %e,
1526 "failed to create delegated subtask"
1527 );
1528 }
1529 }
1530 }
1531
1532 if let Some(ref store) = self.store {
1534 if let Ok(Some(store_id)) = store.resolve_task_id(&self.repo, &parent_id.0).await {
1535 store
1536 .update_status(store_id, crate::store::TaskStatus::Blocked)
1537 .await
1538 .context("store-first: failed to block parent in store")?;
1539 }
1540 }
1541 if let Err(e) = backend.update_status(parent_id, Status::Blocked).await {
1542 tracing::warn!(
1543 parent = parent_id.0,
1544 err = %e,
1545 "failed to mirror blocked status to backend — store is authoritative"
1546 );
1547 }
1548
1549 let summary = delegations
1551 .iter()
1552 .enumerate()
1553 .map(|(i, d)| format!("{}. {}", i + 1, d.title))
1554 .collect::<Vec<_>>()
1555 .join("\n");
1556
1557 backend
1558 .post_comment(
1559 parent_id,
1560 &format!(
1561 "Delegated {} subtask(s):\n\n{}\n\nParent task is blocked until all subtasks complete.{}",
1562 delegations.len(),
1563 summary,
1564 crate::engine::orch_footer()
1565 ),
1566 )
1567 .await?;
1568
1569 Ok(())
1570 }
1571
1572 async fn resolve_project_dir(&self) -> anyhow::Result<PathBuf> {
1574 if let Ok(dir) = std::env::var("PROJECT_DIR") {
1576 if !dir.is_empty() {
1577 return Ok(PathBuf::from(dir));
1578 }
1579 }
1580
1581 if let Ok(paths) = config::get_project_paths() {
1583 for path_str in &paths {
1584 let path = PathBuf::from(path_str);
1585 if let Ok(repo) = config::get_repo_for_project(&path) {
1587 let path_for_check = path.clone();
1589 if repo == self.repo && tokio::fs::metadata(&path_for_check).await.is_ok() {
1590 return Ok(path);
1591 }
1592 }
1593 }
1594 }
1595
1596 if let Ok(dir) = config::get("project_dir") {
1598 if !dir.is_empty() {
1599 let path = PathBuf::from(&dir);
1600 let path_for_check = path.clone();
1601 if tokio::fs::metadata(&path_for_check).await.is_ok() {
1602 return Ok(path);
1603 }
1604 }
1605 }
1606
1607 let parts: Vec<&str> = self.repo.split('/').collect();
1609 if parts.len() == 2 {
1610 let bare = self
1611 .orch_home
1612 .join("projects")
1613 .join(parts[0])
1614 .join(format!("{}.git", parts[1]));
1615 let bare_for_check = bare.clone();
1616 if tokio::task::spawn_blocking(move || bare_for_check.exists())
1617 .await
1618 .unwrap_or(false)
1619 {
1620 return Ok(bare);
1621 }
1622 }
1623
1624 anyhow::bail!(
1629 "no resolvable project directory for repo {} — not in active config, no bare clone at {}, no legacy project_dir set",
1630 self.repo,
1631 self.orch_home
1632 .join("projects")
1633 .join(self.repo.split('/').next().unwrap_or_default())
1634 .join(format!(
1635 "{}.git",
1636 self.repo.split('/').nth(1).unwrap_or_default()
1637 ))
1638 .display()
1639 )
1640 }
1641}
1642
1643async fn update_agent_label_after_reroute(
1649 #[cfg_attr(test, allow(unused_variables))] repo: &str,
1650 task: &ExternalTask,
1651 backend: &Arc<dyn ExternalBackend>,
1652 old_agent: &str,
1653 new_agent: &str,
1654) -> bool {
1655 let old_label = format!("agent:{old_agent}");
1656 if let Err(e) = backend.remove_label(&task.id, &old_label).await {
1657 tracing::warn!(
1658 task_id = ?task.id,
1659 label = old_label,
1660 error = %e,
1661 "failed to remove old agent label during re-route"
1662 );
1663 }
1664 let new_label = format!("agent:{new_agent}");
1665
1666 #[cfg(not(test))]
1669 {
1670 match crate::github::http::GhHttp::new() {
1671 Ok(gh) => {
1672 if gh
1673 .ensure_label(
1674 repo,
1675 &new_label,
1676 crate::github::http::status_label_color(&new_label),
1677 &format!("Agent: {new_agent}"),
1678 )
1679 .await
1680 .is_err()
1681 {
1682 tracing::warn!(
1683 task_id = ?task.id,
1684 label = %new_label,
1685 "ensure_label failed during agent label update after failover"
1686 );
1687 }
1688 }
1689 Err(e) => {
1690 tracing::warn!(
1691 task_id = ?task.id,
1692 err = %e,
1693 "GhHttp::new() failed — skipping ensure_label"
1694 );
1695 }
1696 }
1697 }
1698
1699 if let Err(e) = backend
1700 .set_labels(&task.id, std::slice::from_ref(&new_label))
1701 .await
1702 {
1703 tracing::warn!(
1704 task_id = ?task.id,
1705 label = %new_label,
1706 error = %e,
1707 "set_labels failed during agent label update after failover"
1708 );
1709 return false;
1710 }
1711 true
1712}
1713
1714#[cfg(test)]
1715mod tests {
1716 use super::*;
1717 use crate::backends::{ExternalId, ExternalTask, Mention, Status};
1718 use crate::engine::runner::agents::patterns::safe_tail as safe_utf8_tail;
1719 use crate::parser::AgentResponse;
1720 use crate::store::{NewTask, TaskStore};
1721 use async_trait::async_trait;
1722 use chrono::Utc;
1723 use once_cell::sync::Lazy;
1724 use std::sync::{Arc, Mutex};
1725 use tempfile::TempDir;
1726
1727 static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
1728
1729 fn ok_parse_result(status: &str) -> Result<agents::ParsedResponse, agents::AgentError> {
1730 Ok(agents::ParsedResponse {
1731 response: AgentResponse {
1732 status: status.to_string(),
1733 summary: String::new(),
1734 accomplished: vec![],
1735 remaining: vec![],
1736 files: vec![],
1737 error: None,
1738 input_tokens: None,
1739 output_tokens: None,
1740 learnings: vec![],
1741 delegations: vec![],
1742 },
1743 input_tokens: None,
1744 output_tokens: None,
1745 duration_ms: None,
1746 })
1747 }
1748
1749 #[test]
1752 fn safe_utf8_tail_short_string_returned_as_is() {
1753 assert_eq!(safe_utf8_tail("hello", 100), "hello");
1754 assert_eq!(safe_utf8_tail("", 10), "");
1755 }
1756
1757 #[test]
1758 fn safe_utf8_tail_truncates_ascii() {
1759 let s = "abcdefghij"; assert_eq!(safe_utf8_tail(s, 5), "fghij");
1761 assert_eq!(safe_utf8_tail(s, 10), "abcdefghij");
1762 }
1763
1764 #[test]
1765 fn safe_utf8_tail_handles_multibyte_boundary() {
1766 let s = "日本語";
1768 assert_eq!(s.len(), 9);
1769 let tail = safe_utf8_tail(s, 8);
1773 assert!(
1774 s.ends_with(tail),
1775 "tail must be a valid suffix of the original"
1776 );
1777 assert!(
1778 std::str::from_utf8(tail.as_bytes()).is_ok(),
1779 "tail must be valid UTF-8"
1780 );
1781 }
1782
1783 #[test]
1784 fn safe_utf8_tail_handles_mixed_ascii_and_multibyte() {
1785 let s = "hello日本語world"; for max_bytes in [1, 3, 5, 7, 11, 13, 20, 100] {
1787 let tail = safe_utf8_tail(s, max_bytes);
1788 assert!(
1789 std::str::from_utf8(tail.as_bytes()).is_ok(),
1790 "max_bytes={max_bytes}: tail must be valid UTF-8"
1791 );
1792 assert!(
1793 s.ends_with(tail),
1794 "max_bytes={max_bytes}: tail must be a suffix of original"
1795 );
1796 }
1797 }
1798
1799 #[test]
1800 fn safe_utf8_tail_exact_boundary() {
1801 let s = "abcdef"; assert_eq!(safe_utf8_tail(s, 6), "abcdef"); assert_eq!(safe_utf8_tail(s, 0), ""); }
1805
1806 #[test]
1807 fn parse_success_output_synthesizes_done_from_plain_text() {
1808 let runner = agents::get_runner("claude");
1809 let parsed = parse_success_output(
1810 "123",
1811 "claude",
1812 &*runner,
1813 "No open positions, no trade executions, no conditions change.",
1814 )
1815 .unwrap();
1816
1817 assert_eq!(parsed.response.status, "done");
1818 assert_eq!(
1819 parsed.response.summary,
1820 "No open positions, no trade executions, no conditions change."
1821 );
1822 }
1823
1824 #[test]
1825 fn parse_success_output_synthesizes_needs_review_from_error_text() {
1826 let runner = agents::get_runner("claude");
1827 let parsed =
1828 parse_success_output("123", "claude", &*runner, "Failed to update branch").unwrap();
1829
1830 assert_eq!(parsed.response.status, "needs_review");
1831 assert_eq!(
1832 parsed.response.error.as_deref(),
1833 Some("agent returned plain text instead of JSON")
1834 );
1835 }
1836
1837 #[test]
1838 fn parse_success_output_keeps_empty_text_invalid() {
1839 let runner = agents::get_runner("claude");
1840 let err = parse_success_output("123", "claude", &*runner, "").unwrap_err();
1841
1842 assert!(matches!(err, agents::AgentError::InvalidResponse { .. }));
1843 }
1844
1845 #[test]
1846 fn parse_success_output_preserves_tokens_from_ndjson() {
1847 let ndjson = r#"{"type":"system","subtype":"init","session_id":"abc"}
1852{"type":"assistant","message":{"content":[{"type":"text","text":"Done."}],"usage":{"input_tokens":100,"output_tokens":50}}}
1853{"type":"result","subtype":"success","is_error":false,"duration_ms":5000,"result":"All changes committed successfully","usage":{"input_tokens":1234,"output_tokens":567}}"#;
1854 let runner = agents::get_runner("claude");
1855 let parsed = parse_success_output("456", "claude", &*runner, ndjson).unwrap();
1856
1857 assert_eq!(parsed.response.status, "done");
1858 assert_eq!(parsed.input_tokens, Some(1234));
1859 assert_eq!(parsed.output_tokens, Some(567));
1860 assert_eq!(parsed.duration_ms, Some(5000));
1861 }
1862
1863 #[test]
1864 fn parse_success_output_preserves_tokens_from_minimax_ndjson() {
1865 let ndjson = r#"{"type":"system","subtype":"init"}
1867{"type":"assistant","message":{"content":[{"type":"text","text":"No open positions."}],"usage":{"input_tokens":2000,"output_tokens":300}}}"#;
1868 let runner = agents::get_runner("minimax");
1869 let parsed = parse_success_output("789", "minimax", &*runner, ndjson).unwrap();
1870
1871 assert_eq!(parsed.response.status, "done");
1872 assert_eq!(parsed.input_tokens, Some(2000));
1873 assert_eq!(parsed.output_tokens, Some(300));
1874 }
1875
1876 #[test]
1877 fn classify_run_error_type_recognizes_specific_patterns() {
1878 let cases = [
1879 ("push failed: remote rejected", "push_failed"),
1880 ("No commits between main and branch", "no_commits"),
1881 ("create PR failed: 422", "pr_failed"),
1882 ("invalid response from agent", "parse_error"),
1883 ("parse error: missing json", "parse_error"),
1884 ("exceeded max attempts while rerouting", "max_attempts"),
1885 ("unit test failed", "ci_failure"),
1886 ("build failed: type mismatch", "ci_failure"),
1887 ("lint failed on warning", "ci_failure"),
1888 ("3 tests failed", "ci_failure"),
1889 ("test suite failed: 3 tests failed", "ci_failure"),
1890 ];
1891
1892 for (input, expected) in cases {
1893 assert_eq!(classify_run_error_type(input), expected, "input={input}");
1894 }
1895 }
1896
1897 #[test]
1898 fn classify_run_error_type_falls_back_for_unknown_errors() {
1899 assert_eq!(classify_run_error_type("something unexpected"), "failed");
1900 }
1901
1902 #[test]
1903 fn classify_run_error_type_connection_error_mentioning_pull_request_is_not_pr_failed() {
1904 assert_eq!(
1907 classify_run_error_type("failed to fetch pull request details from GitHub"),
1908 "failed"
1909 );
1910 assert_eq!(
1911 classify_run_error_type("connection reset while reading pull request data"),
1912 "failed"
1913 );
1914 }
1915
1916 #[test]
1917 fn classify_run_error_type_url_with_test_substring_is_not_ci_failure() {
1918 assert_eq!(
1920 classify_run_error_type(
1921 "failed to connect to api.github.com/repos/test-org/repo/pulls/123"
1922 ),
1923 "failed"
1924 );
1925 assert_eq!(
1927 classify_run_error_type("context deadline exceeded testing connection"),
1928 "failed"
1929 );
1930 }
1931
1932 #[test]
1933 fn classify_run_error_type_usage_without_limit_is_not_rate_limit() {
1934 assert_eq!(
1936 classify_run_error_type("token usage: 1245 tokens"),
1937 "failed"
1938 );
1939 assert_eq!(
1940 classify_run_error_type("incorrect usage of git command"),
1941 "failed"
1942 );
1943 assert_eq!(
1945 classify_run_error_type("exceeded usage limit for this billing period"),
1946 "rate_limit"
1947 );
1948 }
1949
1950 #[test]
1951 fn classify_run_error_type_billing_cycle_is_not_auth_error() {
1952 assert_eq!(
1955 classify_run_error_type(
1956 "You've reached your usage limit for this billing cycle. \
1957 Your quota will be refreshed in the next cycle."
1958 ),
1959 "billing_cycle_exhausted"
1960 );
1961 assert_eq!(
1962 classify_run_error_type("quota exhausted for billing cycle"),
1963 "billing_cycle_exhausted"
1964 );
1965 assert_eq!(
1967 classify_run_error_type("billing account suspended"),
1968 "failed"
1969 );
1970 }
1971
1972 #[test]
1973 fn classify_run_error_type_monthly_quota_is_billing_cycle() {
1974 assert_eq!(
1977 classify_run_error_type(
1978 "opencode failed: agent failed: You have exceeded your monthly quota"
1979 ),
1980 "billing_cycle_exhausted"
1981 );
1982 assert_eq!(
1983 classify_run_error_type("You have exceeded your monthly quota"),
1984 "billing_cycle_exhausted"
1985 );
1986 }
1987
1988 #[test]
1989 fn classify_run_outcome_routed_is_success() {
1990 let parse_result = ok_parse_result("routed");
1994 assert_eq!(
1995 classify_run_outcome("routed", &parse_result, false),
1996 "success"
1997 );
1998 }
1999
2000 #[test]
2001 fn classify_run_outcome_blocked_is_blocked() {
2002 let parse_result = ok_parse_result("blocked");
2003 assert_eq!(
2004 classify_run_outcome("blocked", &parse_result, false),
2005 "blocked"
2006 );
2007 }
2008
2009 #[test]
2010 fn classify_run_outcome_non_canonical_is_failed() {
2011 let parse_result = ok_parse_result("fix_deployed");
2014 assert_eq!(
2015 classify_run_outcome("fix_deployed", &parse_result, false),
2016 "failed"
2017 );
2018 }
2019
2020 #[test]
2021 fn classify_run_outcome_kimi_billing_cycle_is_not_rate_limit() {
2022 let parse_result: Result<agents::ParsedResponse, agents::AgentError> =
2027 Err(agents::AgentError::RateLimit {
2028 message: "Failed to authenticate. API Error: 403 You've reached your usage \
2029 limit for this billing cycle. Your quota will be refreshed soon."
2030 .to_string(),
2031 });
2032 assert_eq!(
2033 classify_run_outcome("needs_review", &parse_result, false),
2034 "billing_cycle_exhausted"
2035 );
2036 }
2037
2038 #[test]
2039 fn classify_run_outcome_generic_rate_limit_is_unaffected() {
2040 let parse_result: Result<agents::ParsedResponse, agents::AgentError> =
2043 Err(agents::AgentError::RateLimit {
2044 message: "429 Too Many Requests".to_string(),
2045 });
2046 assert_eq!(
2047 classify_run_outcome("needs_review", &parse_result, false),
2048 "rate_limit"
2049 );
2050 }
2051
2052 #[test]
2053 fn classify_run_outcome_auth_billing_cycle_is_not_failed() {
2054 let parse_result: Result<agents::ParsedResponse, agents::AgentError> =
2058 Err(agents::AgentError::Auth {
2059 message: "billing cycle exhausted for this account".to_string(),
2060 });
2061 assert_eq!(
2062 classify_run_outcome("needs_review", &parse_result, false),
2063 "billing_cycle_exhausted"
2064 );
2065 }
2066
2067 #[test]
2068 fn classify_run_outcome_generic_auth_error_is_unaffected() {
2069 let parse_result: Result<agents::ParsedResponse, agents::AgentError> =
2070 Err(agents::AgentError::Auth {
2071 message: "401 Unauthorized".to_string(),
2072 });
2073 assert_eq!(
2074 classify_run_outcome("needs_review", &parse_result, false),
2075 "failed"
2076 );
2077 }
2078
2079 #[tokio::test]
2080 async fn build_run_audit_success_ignores_stale_last_error() {
2081 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2082 let task_id = store
2083 .create(&NewTask {
2084 repo: "owner/repo".to_string(),
2085 origin: "internal".to_string(),
2086 title: "Test".to_string(),
2087 ..Default::default()
2088 })
2089 .await
2090 .unwrap();
2091 store
2092 .set_fields(
2093 task_id,
2094 &[("last_error", serde_json::json!("no error info available"))],
2095 )
2096 .await
2097 .unwrap();
2098
2099 let runner = TaskRunner::new("owner/repo".to_string()).with_store(store);
2100 let parse_result = ok_parse_result("done");
2101 let started_at = Utc::now();
2102 let audit = runner
2103 .build_run_audit(RunAuditInput {
2104 task_id: &task_id.to_string(),
2105 status: "done",
2106 parse_result: &parse_result,
2107 raw_stdout: "",
2108 raw_stderr: "",
2109 started_at: &started_at,
2110 error_override: None,
2111 elapsed_secs: Some(1),
2112 push_failed: false,
2113 })
2114 .await;
2115
2116 assert_eq!(audit.outcome, "success");
2117 assert!(audit.error.is_empty());
2118 }
2119
2120 #[tokio::test]
2121 async fn build_run_audit_non_success_uses_fallback_error_when_empty() {
2122 let runner = TaskRunner::new("owner/repo".to_string());
2123 let parse_result = ok_parse_result("new");
2124 let started_at = Utc::now();
2125 let audit = runner
2126 .build_run_audit(RunAuditInput {
2127 task_id: "1",
2128 status: "new",
2129 parse_result: &parse_result,
2130 raw_stdout: "",
2131 raw_stderr: "",
2132 started_at: &started_at,
2133 error_override: None,
2134 elapsed_secs: Some(1),
2135 push_failed: false,
2136 })
2137 .await;
2138
2139 assert_eq!(audit.outcome, "failed");
2140 assert_eq!(audit.error, "");
2142 }
2143
2144 #[tokio::test]
2145 async fn build_run_audit_non_canonical_status_reports_explicit_error() {
2146 let runner = TaskRunner::new("owner/repo".to_string());
2150 let parse_result = ok_parse_result("fix_deployed");
2151 let started_at = Utc::now();
2152 let audit = runner
2153 .build_run_audit(RunAuditInput {
2154 task_id: "1",
2155 status: "fix_deployed",
2156 parse_result: &parse_result,
2157 raw_stdout: "",
2158 raw_stderr: "",
2159 started_at: &started_at,
2160 error_override: None,
2161 elapsed_secs: Some(1),
2162 push_failed: false,
2163 })
2164 .await;
2165
2166 assert_eq!(audit.outcome, "failed");
2167 assert_eq!(audit.error, "unrecognized status: fix_deployed");
2168 }
2169
2170 #[tokio::test]
2171 async fn build_run_audit_canonical_non_success_status_no_error_message() {
2172 for status in &["routed"] {
2175 let runner = TaskRunner::new("owner/repo".to_string());
2176 let parse_result = ok_parse_result(status);
2177 let started_at = Utc::now();
2178 let audit = runner
2179 .build_run_audit(RunAuditInput {
2180 task_id: "1",
2181 status,
2182 parse_result: &parse_result,
2183 raw_stdout: "",
2184 raw_stderr: "",
2185 started_at: &started_at,
2186 error_override: None,
2187 elapsed_secs: Some(1),
2188 push_failed: false,
2189 })
2190 .await;
2191
2192 assert_eq!(audit.outcome, "success", "status={status}");
2193 assert_eq!(
2194 audit.error, "",
2195 "status={status}: no error for canonical status"
2196 );
2197 }
2198 }
2199
2200 #[tokio::test]
2201 async fn build_run_audit_blocked_uses_summary_when_error_missing() {
2202 let runner = TaskRunner::new("owner/repo".to_string());
2203 let parse_result = Ok(agents::ParsedResponse {
2204 response: AgentResponse {
2205 status: "blocked".to_string(),
2206 summary: "Could not proceed due to missing credentials".to_string(),
2207 accomplished: vec![],
2208 remaining: vec![],
2209 files: vec![],
2210 error: None,
2211 input_tokens: None,
2212 output_tokens: None,
2213 learnings: vec![],
2214 delegations: vec![],
2215 },
2216 input_tokens: None,
2217 output_tokens: None,
2218 duration_ms: None,
2219 });
2220 let started_at = Utc::now();
2221 let audit = runner
2222 .build_run_audit(RunAuditInput {
2223 task_id: "1",
2224 status: "blocked",
2225 parse_result: &parse_result,
2226 raw_stdout: "",
2227 raw_stderr: "",
2228 started_at: &started_at,
2229 error_override: None,
2230 elapsed_secs: Some(1),
2231 push_failed: false,
2232 })
2233 .await;
2234
2235 assert_eq!(audit.outcome, "blocked");
2236 assert_eq!(audit.error, "Could not proceed due to missing credentials");
2237 }
2238
2239 #[tokio::test]
2242 async fn resolve_project_dir_uses_project_dir_env() {
2243 let runner = TaskRunner::new("owner/repo".to_string());
2245
2246 let dir = tempfile::tempdir().unwrap();
2248 let dir_str = dir.path().to_string_lossy().to_string();
2249
2250 std::env::set_var("PROJECT_DIR", &dir_str);
2255 let result = runner.resolve_project_dir().await;
2256 std::env::remove_var("PROJECT_DIR");
2257
2258 assert!(
2259 result.is_ok(),
2260 "resolve_project_dir should succeed with PROJECT_DIR set"
2261 );
2262 assert_eq!(result.unwrap(), dir.path());
2263 }
2264
2265 #[tokio::test]
2266 async fn resolve_project_dir_empty_project_dir_env_falls_through() {
2267 let runner = TaskRunner::new("owner/testrepo-nonexistent".to_string());
2269 std::env::set_var("PROJECT_DIR", "");
2270 let result = runner.resolve_project_dir().await;
2271 std::env::remove_var("PROJECT_DIR");
2272
2273 assert!(
2277 result.is_err(),
2278 "resolve_project_dir should error when no project dir can be resolved"
2279 );
2280 let err = result.unwrap_err().to_string();
2281 assert!(
2282 err.contains("owner/testrepo-nonexistent"),
2283 "error should name the unresolvable repo: {err}"
2284 );
2285 }
2286
2287 struct TrackingBackend {
2290 sub_tasks_created: Arc<Mutex<Vec<(String, String)>>>, status_updates: Arc<Mutex<Vec<(String, Status)>>>,
2292 comments: Arc<Mutex<Vec<(String, String)>>>, }
2294
2295 impl TrackingBackend {
2296 fn new() -> Self {
2297 Self {
2298 sub_tasks_created: Arc::new(Mutex::new(vec![])),
2299 status_updates: Arc::new(Mutex::new(vec![])),
2300 comments: Arc::new(Mutex::new(vec![])),
2301 }
2302 }
2303 }
2304
2305 #[async_trait]
2306 impl crate::backends::ExternalBackend for TrackingBackend {
2307 fn name(&self) -> &str {
2308 "tracking"
2309 }
2310 async fn create_task(
2311 &self,
2312 _t: &str,
2313 _b: &str,
2314 _l: &[String],
2315 ) -> anyhow::Result<ExternalId> {
2316 Ok(ExternalId("new".to_string()))
2317 }
2318 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
2319 Ok(ExternalTask {
2320 id: id.clone(),
2321 title: "".to_string(),
2322 body: "".to_string(),
2323 state: "open".to_string(),
2324 labels: vec![],
2325 author: "".to_string(),
2326 created_at: "2026-01-01T00:00:00Z".to_string(),
2327 updated_at: "2026-01-01T00:00:00Z".to_string(),
2328 url: "".to_string(),
2329 })
2330 }
2331 async fn list_by_status(&self, _s: Status) -> anyhow::Result<Vec<ExternalTask>> {
2332 Ok(vec![])
2333 }
2334 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2335 Ok(vec![])
2336 }
2337 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
2338 self.comments
2339 .lock()
2340 .unwrap()
2341 .push((id.0.clone(), body.to_string()));
2342 Ok(())
2343 }
2344 async fn set_labels(&self, _id: &ExternalId, _l: &[String]) -> anyhow::Result<()> {
2345 Ok(())
2346 }
2347 async fn remove_label(&self, _id: &ExternalId, _l: &str) -> anyhow::Result<()> {
2348 Ok(())
2349 }
2350 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2351 Ok(vec![])
2352 }
2353 async fn create_sub_task(
2354 &self,
2355 parent: &ExternalId,
2356 title: &str,
2357 _body: &str,
2358 _l: &[String],
2359 ) -> anyhow::Result<ExternalId> {
2360 self.sub_tasks_created
2361 .lock()
2362 .unwrap()
2363 .push((title.to_string(), parent.0.clone()));
2364 Ok(ExternalId(format!("child-{}", title)))
2365 }
2366 async fn ensure_status_label(&self, _l: &str) -> anyhow::Result<()> {
2367 Ok(())
2368 }
2369 async fn has_open_issue_with_title(&self, _t: &str, _l: &str) -> anyhow::Result<bool> {
2370 Ok(false)
2371 }
2372 async fn health_check(&self) -> anyhow::Result<()> {
2373 Ok(())
2374 }
2375 async fn is_pr_merged(&self, _b: &str) -> anyhow::Result<bool> {
2376 Ok(false)
2377 }
2378 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
2379 Ok(None)
2380 }
2381 async fn get_mentions(&self, _s: &str) -> anyhow::Result<Vec<Mention>> {
2382 Ok(vec![])
2383 }
2384 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
2385 self.status_updates
2386 .lock()
2387 .unwrap()
2388 .push((id.0.clone(), status));
2389 Ok(())
2390 }
2391 }
2392
2393 fn make_task(id: &str) -> ExternalTask {
2394 ExternalTask {
2395 id: ExternalId(id.to_string()),
2396 title: format!("Task {id}"),
2397 body: "".to_string(),
2398 state: "open".to_string(),
2399 labels: vec![],
2400 author: "bot".to_string(),
2401 created_at: "2026-01-01T00:00:00Z".to_string(),
2402 updated_at: "2026-01-01T00:00:00Z".to_string(),
2403 url: "".to_string(),
2404 }
2405 }
2406
2407 #[allow(clippy::await_holding_lock)]
2410 #[tokio::test]
2411 async fn process_delegations_creates_subtasks_and_blocks_parent() {
2412 let _guard = ENV_LOCK.lock().unwrap();
2413 let temp_home = TempDir::new().unwrap();
2414 let orch_home = temp_home.path().join(".orch");
2415 tokio::fs::create_dir_all(&orch_home).await.unwrap();
2416 let old_orch_home = std::env::var("ORCH_HOME").ok();
2417 std::env::set_var("ORCH_HOME", &orch_home);
2418
2419 let runner = TaskRunner::new("owner/repo".to_string());
2420 let parent = make_task("99");
2421 let backend = Arc::new(TrackingBackend::new());
2422 let backend_dyn: Arc<dyn crate::backends::ExternalBackend> = backend.clone();
2423
2424 let delegations = vec![
2425 crate::parser::Delegation {
2426 title: "Subtask A".to_string(),
2427 body: "Do A".to_string(),
2428 labels: vec!["enhancement".to_string()],
2429 },
2430 crate::parser::Delegation {
2431 title: "Subtask B".to_string(),
2432 body: "Do B".to_string(),
2433 labels: vec![],
2434 },
2435 ];
2436
2437 runner
2438 .process_delegations(&parent, &delegations, &backend_dyn)
2439 .await
2440 .unwrap();
2441
2442 let created = backend.sub_tasks_created.lock().unwrap();
2444 assert_eq!(created.len(), 2);
2445 assert_eq!(created[0].1, "99", "parent id should be 99");
2446 assert!(
2447 created.iter().any(|(t, _)| t == "Subtask A"),
2448 "Subtask A should be created"
2449 );
2450 assert!(
2451 created.iter().any(|(t, _)| t == "Subtask B"),
2452 "Subtask B should be created"
2453 );
2454 drop(created);
2455
2456 let updates = backend.status_updates.lock().unwrap();
2458 assert!(
2459 updates
2460 .iter()
2461 .any(|(id, s)| id == "99" && *s == Status::Blocked),
2462 "parent should be blocked"
2463 );
2464 drop(updates);
2465
2466 let comments = backend.comments.lock().unwrap();
2468 assert!(
2469 !comments.is_empty(),
2470 "a delegation summary comment should be posted"
2471 );
2472 let comment_body = &comments[0].1;
2473 assert!(
2474 comment_body.contains("Subtask A"),
2475 "summary should mention Subtask A"
2476 );
2477 assert!(
2478 comment_body.contains("Subtask B"),
2479 "summary should mention Subtask B"
2480 );
2481
2482 if let Some(old) = old_orch_home {
2483 std::env::set_var("ORCH_HOME", old);
2484 } else {
2485 std::env::remove_var("ORCH_HOME");
2486 }
2487 }
2488
2489 #[allow(clippy::await_holding_lock)]
2490 #[tokio::test]
2491 async fn process_delegations_single_subtask() {
2492 let _guard = ENV_LOCK.lock().unwrap();
2493 let temp_home = TempDir::new().unwrap();
2494 let orch_home = temp_home.path().join(".orch");
2495 tokio::fs::create_dir_all(&orch_home).await.unwrap();
2496 let old_orch_home = std::env::var("ORCH_HOME").ok();
2497 std::env::set_var("ORCH_HOME", &orch_home);
2498
2499 let runner = TaskRunner::new("owner/repo".to_string());
2500 let parent = make_task("101");
2501 let backend = Arc::new(TrackingBackend::new());
2502 let backend_dyn: Arc<dyn crate::backends::ExternalBackend> = backend.clone();
2503
2504 let delegations = vec![crate::parser::Delegation {
2505 title: "Only Child".to_string(),
2506 body: "Do only child".to_string(),
2507 labels: vec!["feature".to_string()],
2508 }];
2509
2510 runner
2511 .process_delegations(&parent, &delegations, &backend_dyn)
2512 .await
2513 .unwrap();
2514
2515 let created = backend.sub_tasks_created.lock().unwrap();
2516 assert_eq!(created.len(), 1);
2517 assert_eq!(created[0].0, "Only Child");
2518 drop(created);
2519
2520 let updates = backend.status_updates.lock().unwrap();
2521 assert!(
2522 updates
2523 .iter()
2524 .any(|(id, s)| id == "101" && *s == Status::Blocked),
2525 "parent should be blocked"
2526 );
2527 drop(updates);
2528
2529 let comments = backend.comments.lock().unwrap();
2530 let body = &comments[0].1;
2531 assert!(
2532 body.contains("1 subtask"),
2533 "comment should count one subtask"
2534 );
2535
2536 if let Some(old) = old_orch_home {
2537 std::env::set_var("ORCH_HOME", old);
2538 } else {
2539 std::env::remove_var("ORCH_HOME");
2540 }
2541 }
2542
2543 fn weight_signal_for(status: &str, error_type: &str, agent: &str) -> WeightSignal {
2548 let is_rerouted = status == "new" || status == "routed";
2549 let is_rate_limit_error = error_type == "rate_limit";
2550 if is_rerouted {
2551 if is_rate_limit_error {
2552 WeightSignal::RateLimited {
2553 agent: agent.to_string(),
2554 }
2555 } else {
2556 WeightSignal::Rerouted
2557 }
2558 } else if status == "done"
2559 || status == "needs_review"
2560 || status == "in_progress"
2561 || status == "in_review"
2562 {
2563 WeightSignal::Success {
2564 agent: agent.to_string(),
2565 }
2566 } else if status == "blocked" {
2567 WeightSignal::Blocked
2568 } else {
2569 WeightSignal::None
2570 }
2571 }
2572
2573 #[test]
2574 fn weight_signal_success_includes_needs_review() {
2575 let signal = weight_signal_for("needs_review", "success", "claude");
2578 assert!(
2579 matches!(signal, WeightSignal::Success { agent } if agent == "claude"),
2580 "needs_review should produce WeightSignal::Success"
2581 );
2582 }
2583
2584 #[test]
2585 fn weight_signal_success_for_all_done_statuses() {
2586 for status in ["done", "needs_review", "in_progress", "in_review"] {
2587 let signal = weight_signal_for(status, "success", "codex");
2588 assert!(
2589 matches!(signal, WeightSignal::Success { agent } if agent == "codex"),
2590 "{status} should produce WeightSignal::Success"
2591 );
2592 }
2593 }
2594
2595 #[test]
2596 fn weight_signal_rate_limited_for_reroute_with_rate_limit_error() {
2597 let signal = weight_signal_for("new", "rate_limit", "claude");
2599 assert!(matches!(signal, WeightSignal::RateLimited { agent } if agent == "claude"));
2600 }
2601
2602 #[test]
2603 fn weight_signal_rate_limited_for_routed_status_with_rate_limit_error() {
2604 let signal = weight_signal_for("routed", "rate_limit", "claude");
2605 assert!(matches!(signal, WeightSignal::RateLimited { agent } if agent == "claude"));
2606 }
2607
2608 #[test]
2609 fn weight_signal_rerouted_for_non_rate_limit_reroute() {
2610 for error_type in ["timeout", "silence detected", "parse_error", "failed"] {
2615 for status in ["new", "routed"] {
2616 let signal = weight_signal_for(status, error_type, "claude");
2617 assert!(
2618 matches!(signal, WeightSignal::Rerouted),
2619 "{status} with error_type={error_type} should produce WeightSignal::Rerouted, not RateLimited or None"
2620 );
2621 }
2622 }
2623 }
2624
2625 #[test]
2626 fn silence_reset_rerouted_never_becomes_needs_review() {
2627 let signal = weight_signal_for("new", "silence detected", "claude");
2632 assert!(
2633 matches!(signal, WeightSignal::Rerouted),
2634 "silence-reset reroute must produce Rerouted, never None/needs_review"
2635 );
2636 let display_status = match &signal {
2638 WeightSignal::Rerouted => "new",
2639 WeightSignal::None => "needs_review", _ => "other",
2641 };
2642 assert_eq!(
2643 display_status, "new",
2644 "Rerouted must map to 'new' in tick, not 'needs_review'"
2645 );
2646 }
2647
2648 #[test]
2649 fn weight_signal_blocked_status() {
2650 let signal = weight_signal_for("blocked", "success", "claude");
2651 assert!(matches!(signal, WeightSignal::Blocked));
2652 }
2653
2654 struct FailingMockRunner;
2659
2660 impl agents::AgentRunner for FailingMockRunner {
2661 #[cfg(test)]
2662 fn name(&self) -> &str {
2663 "opencode"
2664 }
2665
2666 fn stream_events(&self, _event: &serde_json::Value) -> Vec<agents::StreamEvent> {
2667 vec![]
2668 }
2669
2670 fn build_command(
2671 &self,
2672 _model: Option<&str>,
2673 _timeout_cmd: &str,
2674 _sys_file: &str,
2675 _msg_file: &str,
2676 _permissions: &agents::PermissionRules,
2677 ) -> String {
2678 String::new()
2679 }
2680
2681 fn parse_response(&self, raw: &str) -> Result<agents::ParsedResponse, agents::AgentError> {
2682 Err(agents::AgentError::InvalidResponse {
2684 raw: raw.chars().take(30).collect(),
2685 })
2686 }
2687
2688 fn extract_text(&self, raw: &str) -> Result<String, agents::AgentError> {
2689 Err(agents::AgentError::InvalidResponse {
2690 raw: raw.to_string(),
2691 })
2692 }
2693
2694 fn classify_error(
2695 &self,
2696 exit_code: i32,
2697 _stdout: &str,
2698 _stderr: &str,
2699 ) -> agents::AgentError {
2700 agents::AgentError::Unknown {
2701 exit_code,
2702 message: "mock error".into(),
2703 }
2704 }
2705
2706 fn router_command(
2707 &self,
2708 _prompt: &str,
2709 _model: Option<&str>,
2710 ) -> anyhow::Result<tokio::process::Command> {
2711 anyhow::bail!("not implemented for mock")
2712 }
2713 }
2714
2715 #[test]
2716 fn parse_success_output_extracts_tokens_from_raw_stdout_not_error_raw() {
2717 let ndjson = r#"{"type":"text","timestamp":1,"part":{"type":"text","text":"Here is my response: {\"status\":\"done\",\"summary\":\"fixed\"}}"}}
2719{"type":"step_finish","timestamp":2,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":95177,"input":94920,"output":257}}}"#;
2720
2721 let runner = FailingMockRunner;
2722 let result = parse_success_output("1550", "opencode", &runner, ndjson);
2723
2724 let parsed = result.expect("should synthesize response from text");
2726 assert_eq!(parsed.input_tokens, Some(94920));
2727 assert_eq!(parsed.output_tokens, Some(257));
2728 }
2729
2730 #[test]
2731 fn parse_success_output_no_tokens_when_ndjson_has_none() {
2732 let text = "No open positions, no trade executions, no conditions change.";
2735
2736 let runner = FailingMockRunner;
2737 let result = parse_success_output("1551", "opencode", &runner, text);
2738
2739 let parsed = result.expect("should synthesize response from explicit-done text");
2741 assert_eq!(parsed.input_tokens, None);
2742 assert_eq!(parsed.output_tokens, None);
2743 }
2744
2745 #[test]
2746 fn parse_success_output_uses_agent_result_directly() {
2747 let ndjson = r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"status\":\"done\",\"summary\":\"my summary\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}"#;
2750
2751 let runner = FailingMockRunner;
2753 let result = parse_success_output("999", "claude", &runner, ndjson);
2754
2755 let parsed = result.expect("should parse successfully via find_agent_result");
2756 assert_eq!(parsed.response.status, "done");
2757 assert_eq!(parsed.response.summary, "my summary");
2758 }
2759
2760 #[test]
2761 fn parse_success_output_treats_agent_result_is_error_as_authoritative() {
2762 let ndjson = r#"{"type":"result","subtype":"success","is_error":true,"result":"I failed because of reasons"}"#;
2765
2766 let runner = FailingMockRunner;
2767 let result = parse_success_output("999", "claude", &runner, ndjson);
2768
2769 let err = result.expect_err("should return error");
2770 match err {
2771 agents::AgentError::AgentFailed { message } => {
2772 assert_eq!(message, "I failed because of reasons");
2773 }
2774 _ => panic!("expected AgentFailed, got {err:?}"),
2775 }
2776 }
2777
2778 #[test]
2779 fn parse_success_output_classifies_model_unavailable_from_is_error_result() {
2780 let ndjson = r#"{"type":"result","subtype":"success","is_error":true,"result":"model unavailable (gpt-5.5): Reconnecting... 2/5 (unexpected status 404 Not Found: The model `gpt-5.5` does not exist or you do not have access to it.)"}"#;
2787
2788 let runner = FailingMockRunner;
2789 let result = parse_success_output("999", "claude", &runner, ndjson);
2790
2791 let err = result.expect_err("should return error");
2792 match err {
2793 agents::AgentError::ModelUnavailable { model, .. } => {
2794 assert_eq!(model, "gpt-5.5");
2795 }
2796 _ => panic!("expected ModelUnavailable, got {err:?}"),
2797 }
2798 }
2799
2800 #[test]
2801 fn nonzero_exit_with_ndjson_success_is_parsed_as_success() {
2802 let ndjson = r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":1234,"result":"{\"status\":\"done\",\"summary\":\"All good\"}","usage":{"input_tokens":10,"output_tokens":5}}"#;
2806
2807 let runner = FailingMockRunner;
2811
2812 let session_output = session::SessionOutput {
2814 exit_code: 1,
2815 raw_stdout: ndjson.to_string(),
2816 raw_stderr: String::new(),
2817 elapsed_secs: Some(1),
2818 };
2819
2820 let parsed =
2823 parse_success_output("task-ndjson", "claude", &runner, &session_output.raw_stdout)
2824 .expect("should parse NDJSON success");
2825 assert_eq!(parsed.response.status, "done");
2826 assert_eq!(parsed.response.summary, "All good");
2827 assert_eq!(parsed.input_tokens, Some(10));
2828 assert_eq!(parsed.output_tokens, Some(5));
2829 }
2830
2831 struct LabelFailingBackend {
2835 inner: TrackingBackend,
2836 set_labels_err: Option<String>,
2838 }
2839
2840 impl LabelFailingBackend {
2841 fn new() -> Self {
2842 Self {
2843 inner: TrackingBackend::new(),
2844 set_labels_err: None,
2845 }
2846 }
2847 fn with_set_labels_err(mut self, err: impl Into<String>) -> Self {
2848 self.set_labels_err = Some(err.into());
2849 self
2850 }
2851 }
2852
2853 impl Default for LabelFailingBackend {
2854 fn default() -> Self {
2855 Self::new()
2856 }
2857 }
2858
2859 #[async_trait]
2860 impl crate::backends::ExternalBackend for LabelFailingBackend {
2861 fn name(&self) -> &str {
2862 "label_failing"
2863 }
2864 async fn create_task(
2865 &self,
2866 _t: &str,
2867 _b: &str,
2868 _l: &[String],
2869 ) -> anyhow::Result<ExternalId> {
2870 Ok(ExternalId("new".to_string()))
2871 }
2872 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
2873 self.inner.get_task(id).await
2874 }
2875 async fn list_by_status(&self, _s: Status) -> anyhow::Result<Vec<ExternalTask>> {
2876 Ok(vec![])
2877 }
2878 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2879 Ok(vec![])
2880 }
2881 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
2882 self.inner.post_comment(id, body).await
2883 }
2884 async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
2885 if let Some(ref err) = self.set_labels_err {
2886 return Err(anyhow::anyhow!("{}", err));
2887 }
2888 self.inner.set_labels(id, labels).await
2889 }
2890 async fn remove_label(&self, id: &ExternalId, label: &str) -> anyhow::Result<()> {
2891 self.inner.remove_label(id, label).await
2892 }
2893 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2894 Ok(vec![])
2895 }
2896 async fn create_sub_task(
2897 &self,
2898 parent: &ExternalId,
2899 title: &str,
2900 body: &str,
2901 labels: &[String],
2902 ) -> anyhow::Result<ExternalId> {
2903 self.inner
2904 .create_sub_task(parent, title, body, labels)
2905 .await
2906 }
2907 async fn ensure_status_label(&self, label: &str) -> anyhow::Result<()> {
2908 self.inner.ensure_status_label(label).await
2909 }
2910 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
2911 self.inner.update_status(id, status).await
2912 }
2913 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
2914 self.inner.get_authenticated_user().await
2915 }
2916 async fn get_mentions(&self, _s: &str) -> anyhow::Result<Vec<Mention>> {
2917 Ok(vec![])
2918 }
2919 async fn health_check(&self) -> anyhow::Result<()> {
2920 self.inner.health_check().await
2921 }
2922 }
2923
2924 #[tokio::test]
2929 async fn update_agent_label_returns_false_on_set_labels_failure() {
2930 let task = ExternalTask {
2931 id: ExternalId("test-reroute-1".to_string()),
2932 title: "Test".to_string(),
2933 body: "body".to_string(),
2934 state: "open".to_string(),
2935 labels: vec![],
2936 author: "bot".to_string(),
2937 created_at: "2026-01-01T00:00:00Z".to_string(),
2938 updated_at: "2026-01-01T00:00:00Z".to_string(),
2939 url: "".to_string(),
2940 };
2941 let backend: Arc<dyn crate::backends::ExternalBackend> =
2942 Arc::new(LabelFailingBackend::new().with_set_labels_err("network error"));
2943
2944 let result =
2945 update_agent_label_after_reroute("owner/repo", &task, &backend, "claude", "codex")
2946 .await;
2947
2948 assert!(
2949 !result,
2950 "update_agent_label_after_reroute should return false when set_labels fails"
2951 );
2952 }
2953
2954 #[tokio::test]
2956 async fn update_agent_label_returns_true_on_success() {
2957 let task = ExternalTask {
2958 id: ExternalId("test-reroute-2".to_string()),
2959 title: "Test".to_string(),
2960 body: "body".to_string(),
2961 state: "open".to_string(),
2962 labels: vec![],
2963 author: "bot".to_string(),
2964 created_at: "2026-01-01T00:00:00Z".to_string(),
2965 updated_at: "2026-01-01T00:00:00Z".to_string(),
2966 url: "".to_string(),
2967 };
2968 let backend: Arc<dyn crate::backends::ExternalBackend> =
2970 Arc::new(LabelFailingBackend::new());
2971
2972 let result =
2973 update_agent_label_after_reroute("owner/repo", &task, &backend, "claude", "codex")
2974 .await;
2975
2976 assert!(
2978 result,
2979 "update_agent_label_after_reroute should return true when set_labels succeeds"
2980 );
2981 }
2982
2983 #[test]
2989 fn parse_success_output_ndjson_envelope_fallback_domain_json_result() {
2990 let domain_result = r#"{"file_path":"md/journal/2026-05-08-morning-briefing.md","summary":"Morning briefing filed","health":{"status":"ok"},"priorities":[]}"#;
2991 let ndjson = format!(
2992 r#"{{"type":"result","subtype":"success","is_error":false,"terminal_reason":"completed","result":{domain_result:?},"usage":{{"input_tokens":3000,"output_tokens":400}}}}"#
2993 );
2994
2995 let runner = agents::get_runner("glm");
2996 let parsed = parse_success_output("149200", "glm", &*runner, &ndjson)
2997 .expect("should synthesize done from NDJSON envelope when result is domain JSON");
2998
2999 assert_eq!(parsed.response.status, "done");
3000 assert!(
3001 parsed.response.summary.contains("file_path") || !parsed.response.summary.is_empty(),
3002 "summary should be populated from domain JSON result text"
3003 );
3004 assert_eq!(parsed.input_tokens, Some(3000));
3005 assert_eq!(parsed.output_tokens, Some(400));
3006 }
3007
3008 #[test]
3012 fn parse_success_output_ndjson_envelope_fallback_plain_prose_result() {
3013 let prose = "PR #1008 now has zero diff vs main. The reverted ledger changes are gone and the branch is clean.";
3014 let ndjson = format!(
3015 r#"{{"type":"result","subtype":"success","is_error":false,"terminal_reason":"completed","result":{prose:?},"usage":{{"input_tokens":2500,"output_tokens":300}}}}"#
3016 );
3017
3018 let runner = agents::get_runner("claude");
3019 let parsed = parse_success_output("149164", "claude", &*runner, &ndjson)
3020 .expect("should synthesize done from NDJSON envelope when result is plain prose");
3021
3022 assert_eq!(parsed.response.status, "done");
3023 assert_eq!(parsed.response.summary, prose);
3024 assert_eq!(parsed.input_tokens, Some(2500));
3025 assert_eq!(parsed.output_tokens, Some(300));
3026 }
3027
3028 #[test]
3031 fn parse_success_output_ndjson_envelope_fallback_skipped_when_is_error_true() {
3032 let ndjson = r#"{"type":"result","subtype":"error","is_error":true,"result":"Something went wrong with no status"}"#;
3033
3034 let runner = agents::get_runner("claude");
3035 let err = parse_success_output("149999", "claude", &*runner, ndjson)
3036 .expect_err("is_error=true should return AgentFailed");
3037
3038 assert!(matches!(err, agents::AgentError::AgentFailed { .. }));
3039 }
3040
3041 #[test]
3050 fn parse_session_output_exit1_with_terminal_reason_completed_returns_invalid_response() {
3051 let ndjson = r#"{"type":"result","subtype":"success","is_error":false,"terminal_reason":"completed","result":"","usage":{"input_tokens":1000,"output_tokens":100}}"#;
3057 let session = session::SessionOutput {
3058 exit_code: 1,
3059 raw_stdout: ndjson.to_string(),
3060 raw_stderr: String::new(),
3061 elapsed_secs: Some(30),
3062 };
3063
3064 let runner = agents::get_runner("claude");
3065 let result = parse_session_output("149298", "claude", &*runner, &session);
3066
3067 let err = result.expect_err("should be an error, not Ok");
3068 assert!(
3069 matches!(err, agents::AgentError::InvalidResponse { .. }),
3070 "should be InvalidResponse, got {err:?}"
3071 );
3072 }
3073
3074 #[test]
3080 fn parse_session_output_exit1_with_cost_telemetry_returns_invalid_response() {
3081 let telemetry = r#"{"type":"result","subtype":"success","is_error":false,"result":"","costUSD":0.042,"usage":{"input_tokens":5000,"output_tokens":800}}"#;
3083 let session = session::SessionOutput {
3084 exit_code: 1,
3085 raw_stdout: telemetry.to_string(),
3086 raw_stderr: String::new(),
3087 elapsed_secs: Some(120),
3088 };
3089
3090 let runner = agents::get_runner("glm");
3091 let result = parse_session_output("149347", "glm", &*runner, &session);
3092
3093 let err = result.expect_err("should be an error, not Ok");
3094 assert!(
3095 matches!(err, agents::AgentError::InvalidResponse { .. }),
3096 "glm cost-telemetry exit-1 should return InvalidResponse, got {err:?}"
3097 );
3098 }
3099
3100 #[test]
3103 fn parse_session_output_exit1_cost_telemetry_with_is_error_true_propagates_failure() {
3104 let telemetry = r#"{"type":"result","subtype":"error","is_error":true,"result":"tool failed","costUSD":0.01,"usage":{"input_tokens":100,"output_tokens":10}}"#;
3105 let session = session::SessionOutput {
3106 exit_code: 1,
3107 raw_stdout: telemetry.to_string(),
3108 raw_stderr: String::new(),
3109 elapsed_secs: Some(15),
3110 };
3111
3112 let runner = agents::get_runner("glm");
3113 let result = parse_session_output("149348", "glm", &*runner, &session);
3114
3115 let err = result.expect_err("should be an error");
3116 assert!(
3117 matches!(err, agents::AgentError::AgentFailed { .. }),
3118 "is_error=true with costUSD must still return AgentFailed, got {err:?}"
3119 );
3120 }
3121
3122 #[test]
3128 fn parse_session_output_exit1_preserves_error_when_is_error_true() {
3129 let ndjson = r#"{"type":"result","subtype":"error","is_error":true,"terminal_reason":"completed","result":"failed"}"#;
3130 let session = session::SessionOutput {
3131 exit_code: 1,
3132 raw_stdout: ndjson.to_string(),
3133 raw_stderr: String::new(),
3134 elapsed_secs: Some(30),
3135 };
3136
3137 let runner = agents::get_runner("kimi");
3138 let result = parse_session_output("149299", "kimi", &*runner, &session);
3139
3140 let err = result.expect_err("should be an error");
3141 assert!(
3142 matches!(err, agents::AgentError::AgentFailed { .. }),
3143 "is_error=true should return AgentFailed, got {err:?}"
3144 );
3145 }
3146
3147 #[test]
3153 fn parse_session_output_exit1_with_rate_limit_and_terminal_reason_completed() {
3154 let ndjson = r#"{"type":"result","is_error":true,"terminal_reason":"completed","result":"You've hit your session limit · resets 2:20pm (America/Sao_Paulo)","usage":{"input_tokens":0,"output_tokens":0}}"#;
3155 let session = session::SessionOutput {
3156 exit_code: 1,
3157 raw_stdout: ndjson.to_string(),
3158 raw_stderr: String::new(),
3159 elapsed_secs: Some(120),
3160 };
3161
3162 let runner = agents::get_runner("claude");
3163 let result = parse_session_output("152895", "claude", &*runner, &session);
3164
3165 let err = result.expect_err("rate-limit should be an error");
3166 assert!(
3167 matches!(err, agents::AgentError::RateLimit { .. }),
3168 "session limit should return RateLimit, not downgraded, got {err:?}"
3169 );
3170 }
3171}