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