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