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 session;
30pub mod task_init;
31pub mod worktree;
32
33use crate::backends::{ExternalBackend, ExternalTask, Status};
34use crate::config;
35use crate::engine::router::RouteResult;
36use crate::engine::tasks::is_internal_id;
37use crate::security;
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 = match input.error_override.as_deref().filter(|s| !s.is_empty()) {
629 Some(error_override) => error_override.to_string(),
630 None => match last_error {
631 Some(last_error) => last_error,
632 None => match input.parse_result {
633 Ok(parsed) => parsed.response.error.clone().unwrap_or_default(),
634 Err(err) => err.to_string(),
635 },
636 },
637 };
638
639 if error.is_empty() {
640 if input.status == "blocked" {
641 match input.parse_result {
642 Ok(parsed) if !parsed.response.summary.is_empty() => {
643 parsed.response.summary.clone()
644 }
645 _ => "task blocked without explicit error".to_string(),
646 }
647 } else if matches!(
648 input.status,
649 "done"
650 | "completed"
651 | "in_progress"
652 | "in_review"
653 | "needs_review"
654 | "routed"
655 | "new"
656 ) {
657 String::new()
659 } else {
660 format!("unrecognized status: {}", input.status)
664 }
665 } else {
666 error
667 }
668 };
669
670 let total_cost_usd = match &self.store {
671 Some(_) => {
672 store::get_cost_estimate(&self.store, &self.repo, input.task_id)
673 .await
674 .total_cost_usd
675 }
676 None => 0.0,
677 };
678
679 let (input_tokens, output_tokens) = extract_run_tokens(input.parse_result);
680
681 let duration_secs = input
682 .elapsed_secs
683 .map(|s| s as f64)
684 .unwrap_or_else(|| run_duration_secs(input.started_at));
685
686 RunAudit {
687 stdout: input.raw_stdout.to_string(),
688 stderr: input.raw_stderr.to_string(),
689 parsed_response: serialize_parsed_response(input.parse_result, input.raw_stdout),
690 outcome,
691 error,
692 input_tokens,
693 output_tokens,
694 total_cost_usd,
695 duration_secs,
696 }
697 }
698
699 pub async fn run(
704 &self,
705 task_id: &str,
706 agent: Option<&str>,
707 model: Option<&str>,
708 backend: Option<&dyn ExternalBackend>,
709 started_at: &chrono::DateTime<Utc>,
710 ) -> anyhow::Result<Option<RunExecution>> {
711 tracing::info!(
712 task_id,
713 agent = agent.unwrap_or("default"),
714 model = model.unwrap_or("default"),
715 "starting task execution"
716 );
717
718 let attempts = match task_init::check_guards(task_id, &self.repo, &self.store).await {
720 Ok(task_init::GuardOutcome::Proceed(a)) => a,
721 Ok(task_init::GuardOutcome::Skip) => {
722 return Ok(None);
723 }
724 Ok(task_init::GuardOutcome::MaxAttempts) => {
725 if backend.is_some() {
728 let route_reason = self
731 .get_field(task_id, "route_reason")
732 .await
733 .unwrap_or_default();
734 if route_reason.starts_with("label agent:") {
735 let agent_label = route_reason.trim_start_matches("label ");
736 let gh = crate::github::http::GhHttp::new()?;
737 if let Err(e) = gh.remove_label(&self.repo, task_id, agent_label).await {
738 tracing::warn!(task_id, label = agent_label, err = %e, "failed to remove agent label after max attempts");
739 } else {
740 tracing::info!(task_id, label = agent_label, "removed forced agent label after max attempts — /retry will use free routing");
741 }
742 }
743 }
744 return Ok(Some(RunExecution {
745 status: "blocked".to_string(),
746 exit_code: None,
747 audit: RunAudit {
748 stdout: String::new(),
749 stderr: String::new(),
750 parsed_response: String::new(),
751 outcome: "failed".to_string(),
752 error: "max attempts reached".to_string(),
753 input_tokens: 0,
754 output_tokens: 0,
755 total_cost_usd: 0.0,
756 duration_secs: 0.0,
757 },
758 }));
759 }
760 Err(e) => return Err(e),
761 };
762
763 let project_dir = self.resolve_project_dir().await?;
765
766 let init = task_init::prepare_task(
768 task_id,
769 agent,
770 model,
771 backend,
772 &self.repo,
773 &project_dir,
774 attempts,
775 &self.store,
776 )
777 .await?;
778
779 let (tmux, tmux_session, session_output) = session::run_agent_session(
781 task_id,
782 &init.invocation,
783 &init.attempt_dir,
784 &self.orch_home,
785 )
786 .await;
787
788 store::store_touch_updated_at(&self.store, &self.repo, task_id).await;
794
795 if let Some(stored) = store::opt_store_get_task(&self.store, &self.repo, task_id).await {
803 let silence_reset = matches!(
804 stored.status,
805 crate::store::TaskStatus::New
806 | crate::store::TaskStatus::Routed
807 | crate::store::TaskStatus::NeedsReview
808 );
809 if silence_reset {
810 let status_str = stored.status.as_str();
811 tracing::info!(
812 task_id,
813 status = status_str,
814 "task already reset by silence detection — skipping fallback processing"
815 );
816 session::cleanup_session(task_id, &tmux, &tmux_session).await;
817 let silence_err = agents::patterns::classify_from_text(0, "silence detected");
818 let silence_parse: Result<agents::ParsedResponse, agents::AgentError> =
819 Err(silence_err);
820 let audit = self
821 .build_run_audit(RunAuditInput {
822 task_id,
823 status: status_str,
824 parse_result: &silence_parse,
825 raw_stdout: &session_output.raw_stdout,
826 raw_stderr: &session_output.raw_stderr,
827 started_at,
828 error_override: Some(format!("silence detection set task to {status_str}")),
829 elapsed_secs: session_output.elapsed_secs,
830 push_failed: false,
831 })
832 .await;
833 return Ok(Some(RunExecution {
834 status: status_str.to_string(),
835 exit_code: Some(session_output.exit_code),
836 audit,
837 }));
838 }
839 }
840
841 let stdout_len = session_output.raw_stdout.len();
843 let stderr_len = session_output.raw_stderr.len();
844 let stdout_tail: String = session_output
845 .raw_stdout
846 .chars()
847 .rev()
848 .take(500)
849 .collect::<String>()
850 .chars()
851 .rev()
852 .collect();
853 let stderr_tail: String = session_output
854 .raw_stderr
855 .chars()
856 .rev()
857 .take(500)
858 .collect::<String>()
859 .chars()
860 .rev()
861 .collect();
862 tracing::info!(
863 task_id,
864 exit_code = session_output.exit_code,
865 stdout_len,
866 stderr_len,
867 stdout_tail = %stdout_tail,
868 stderr_tail = %stderr_tail,
869 "agent raw output"
870 );
871
872 let agent_runner = agents::get_runner(&init.agent_name);
880 let parse_result =
881 parse_session_output(task_id, &init.agent_name, &*agent_runner, &session_output);
882
883 response_handler::write_result_json(
885 &init.attempt_dir,
886 task_id,
887 &init.agent_name,
888 init.model_name.as_deref(),
889 session_output.exit_code,
890 init.new_attempts,
891 &parse_result,
892 &session_output.raw_stdout,
893 &session_output.raw_stderr,
894 )
895 .await;
896
897 let (final_status, push_failed, fallback_error) = match parse_result {
904 Ok(ref parsed) => {
905 let (status, push_failed) = response_handler::handle_success(
906 task_id,
907 parsed.clone(),
908 &init.wt,
909 &init.task_title,
910 &init.agent_name,
911 init.model_name.as_deref(),
912 init.new_attempts,
913 &self.repo,
914 &self.store,
915 &session_output.raw_stdout,
916 )
917 .await?;
918 (status, push_failed, None)
919 }
920 Err(ref agent_err) => {
921 match fallback::handle_error(
922 task_id,
923 agent_err,
924 &init.agent_name,
925 &*agent_runner,
926 init.model_name.as_deref(),
927 init.complexity.as_deref(),
928 init.new_attempts,
929 &self.store,
930 &self.repo,
931 )
932 .await?
933 {
934 fallback::ErrorHandleResult::EarlyReturn { status } => {
935 session::cleanup_session(task_id, &tmux, &tmux_session).await;
937 let audit = self
945 .build_run_audit(RunAuditInput {
946 task_id,
947 status: &status,
948 parse_result: &parse_result,
949 raw_stdout: &session_output.raw_stdout,
950 raw_stderr: &session_output.raw_stderr,
951 started_at,
952 error_override: None,
953 elapsed_secs: session_output.elapsed_secs,
954 push_failed: false,
955 })
956 .await;
957 return Ok(Some(RunExecution {
958 status,
959 exit_code: Some(session_output.exit_code),
960 audit,
961 }));
962 }
963 fallback::ErrorHandleResult::Continue { status, error } => {
964 (status, false, Some(error))
965 }
966 }
967 }
968 };
969
970 session::cleanup_session(task_id, &tmux, &tmux_session).await;
972
973 if final_status.as_str() == "done" {
977 if let Some(ref st) = self.store {
978 if let Err(e) =
979 crate::engine::cleanup::cleanup_task_worktree(task_id, &self.repo, st).await
980 {
981 tracing::warn!(task_id, error = ?e, "worktree cleanup failed after no-op done");
982 }
983 }
984 }
985
986 let audit = self
991 .build_run_audit(RunAuditInput {
992 task_id,
993 status: &final_status,
994 parse_result: &parse_result,
995 raw_stdout: &session_output.raw_stdout,
996 raw_stderr: &session_output.raw_stderr,
997 started_at,
998 error_override: fallback_error,
999 elapsed_secs: session_output.elapsed_secs,
1000 push_failed,
1001 })
1002 .await;
1003
1004 Ok(Some(RunExecution {
1005 status: final_status.to_string(),
1006 exit_code: Some(session_output.exit_code),
1007 audit,
1008 }))
1009 }
1010
1011 #[allow(clippy::too_many_arguments)]
1013 async fn record_metrics(
1014 &self,
1015 task_id: &str,
1016 agent_name: &str,
1017 model_name: &Option<String>,
1018 route_result: &Option<RouteResult>,
1019 started_at: &chrono::DateTime<Utc>,
1020 attempts: u32,
1021 final_status: &str,
1022 error_type: Option<&str>,
1023 ) {
1024 let completed_at = Utc::now();
1025 let duration_seconds = (completed_at - *started_at).num_milliseconds() as f64 / 1000.0;
1026
1027 let outcome = match final_status {
1029 "done" | "in_progress" | "in_review" => "success",
1030 "new" => "rerouted",
1031 "needs_review" => classify_run_error_type(error_type.unwrap_or("")),
1032 _ => "unknown",
1033 };
1034
1035 let complexity = route_result.as_ref().map(|r| r.complexity.clone());
1036 let files_changed = git_ops::count_changed_files(&PathBuf::from(
1037 self.get_field(task_id, "worktree")
1038 .await
1039 .unwrap_or_default(),
1040 ))
1041 .await
1042 .unwrap_or(0);
1043
1044 if let Some(ref store) = self.store {
1045 let db_error_type: Option<String> = if outcome == "success" {
1047 None
1048 } else {
1049 Some(outcome.to_string())
1050 };
1051
1052 let usage = store::get_token_usage(&self.store, &self.repo, task_id).await;
1054 let cost = store::get_cost_estimate(&self.store, &self.repo, task_id).await;
1055 let input_tokens = if usage.input_tokens > 0 {
1056 Some(usage.input_tokens as i64)
1057 } else {
1058 None
1059 };
1060 let output_tokens = if usage.output_tokens > 0 {
1061 Some(usage.output_tokens as i64)
1062 } else {
1063 None
1064 };
1065 let input_cost = if cost.input_cost_usd > 0.0 {
1066 Some(cost.input_cost_usd)
1067 } else {
1068 None
1069 };
1070 let output_cost = if cost.output_cost_usd > 0.0 {
1071 Some(cost.output_cost_usd)
1072 } else {
1073 None
1074 };
1075 let total_cost = if cost.total_cost_usd > 0.0 {
1076 Some(cost.total_cost_usd)
1077 } else {
1078 None
1079 };
1080
1081 let metric = InsertTaskMetric {
1082 repo: &self.repo,
1083 task_id,
1084 agent: agent_name,
1085 model: model_name.as_deref(),
1086 complexity: complexity.as_deref(),
1087 outcome,
1088 duration_seconds,
1089 started_at,
1090 completed_at: &completed_at,
1091 attempts: attempts as i32 + 1,
1092 files_changed: files_changed as i32,
1093 error_type: db_error_type.as_deref(),
1094 input_tokens,
1095 output_tokens,
1096 input_cost_usd: input_cost,
1097 output_cost_usd: output_cost,
1098 total_cost_usd: total_cost,
1099 };
1100
1101 if let Err(e) = store.insert_task_metric(&metric).await {
1102 tracing::error!(task_id, ?e, "failed to record task metrics");
1103 }
1104 }
1105 }
1106
1107 pub async fn run_with_context(
1112 &self,
1113 task: &ExternalTask,
1114 backend: &Arc<dyn ExternalBackend>,
1115 _tmux: &Arc<TmuxManager>,
1116 route_result: Option<&RouteResult>,
1117 ) -> anyhow::Result<WeightSignal> {
1118 let task_id = &task.id.0;
1119 let agent = route_result.map(|r| r.agent.as_str());
1120 let agent_name = agent.unwrap_or("claude").to_string();
1121 let model = route_result.and_then(|r| r.model.as_deref());
1122
1123 let started_at = Utc::now();
1125
1126 let run_audit_id = if let Some(ref store) = self.store {
1130 let attempt: i32 = self
1131 .get_field(task_id, "attempts")
1132 .await
1133 .and_then(|s| s.parse().ok())
1134 .unwrap_or(0)
1135 + 1;
1136 if let Ok(Some(store_id)) = store.resolve_task_id(&self.repo, task_id).await {
1137 match store
1138 .start_run(&crate::store::StartRun {
1139 task_id: store_id,
1140 attempt,
1141 run_type: "agent",
1142 agent: &agent_name,
1143 model: model.unwrap_or(""),
1144 command: &format!("{} --model {}", agent_name, model.unwrap_or("default")),
1145 prompt: &task.body,
1146 })
1147 .await
1148 {
1149 Ok(run_id) => Some(run_id),
1150 Err(e) => {
1151 tracing::warn!(
1152 task_id,
1153 error = %e,
1154 "failed to record run start in audit trail"
1155 );
1156 None
1157 }
1158 }
1159 } else {
1160 None
1161 }
1162 } else {
1163 None
1164 };
1165
1166 let run_result = match self
1169 .run(task_id, agent, model, Some(&**backend), &started_at)
1170 .await
1171 {
1172 Ok(run_result) => run_result,
1173 Err(e) => {
1174 if let Some(run_id) = run_audit_id {
1175 if let Some(ref store) = self.store {
1176 if let Err(complete_err) = store
1177 .complete_run(&crate::store::CompleteRun {
1178 run_id,
1179 exit_code: Some(-1),
1180 stdout: "",
1181 stderr: "",
1182 parsed: "",
1183 outcome: "failed",
1184 error: &e.to_string(),
1185 tokens: crate::store::RunTokenUsage::default(),
1186 })
1187 .await
1188 {
1189 tracing::warn!(
1190 task_id,
1191 run_id,
1192 error = %complete_err,
1193 "failed to record run completion in audit trail after runner error"
1194 );
1195 }
1196 }
1197 }
1198 return Err(e);
1199 }
1200 };
1201
1202 let (status, exit_code_opt, run_audit) = match run_result {
1204 Some(result) => (result.status, result.exit_code, result.audit),
1205 None => {
1206 if let Some(run_id) = run_audit_id {
1207 if let Some(ref store) = self.store {
1208 if let Err(e) = store
1209 .complete_run(&crate::store::CompleteRun {
1210 run_id,
1211 exit_code: Some(-1),
1212 stdout: "",
1213 stderr: "",
1214 parsed: "",
1215 outcome: "aborted",
1216 error: "run skipped by guard",
1217 tokens: crate::store::RunTokenUsage::default(),
1218 })
1219 .await
1220 {
1221 tracing::warn!(
1222 task_id,
1223 run_id,
1224 error = %e,
1225 "failed to record skipped run completion in audit trail"
1226 );
1227 }
1228 }
1229 }
1230 tracing::info!(task_id, "guard skipped task — not posting stale result");
1231 return Ok(WeightSignal::None);
1232 }
1233 };
1234
1235 let delegations_raw = self
1237 .get_field(task_id, "delegations")
1238 .await
1239 .unwrap_or_default();
1240 if !delegations_raw.is_empty() {
1241 match serde_json::from_str::<Vec<crate::parser::Delegation>>(&delegations_raw) {
1242 Ok(delegations) if !delegations.is_empty() => {
1243 self.process_delegations(task, &delegations, backend)
1244 .await?;
1245 if let Err(e) = store::store_set_result(
1246 &self.store,
1247 &self.repo,
1248 task_id,
1249 &[("delegations", serde_json::json!([]))],
1250 )
1251 .await
1252 {
1253 tracing::warn!(task_id, err = %e, "failed to clear delegations in store");
1254 }
1255 }
1256 Ok(_) => {} Err(e) => {
1258 tracing::error!(task_id, error = %e, "corrupt delegations JSON — clearing");
1259 if let Err(e2) = store::store_set_result(
1260 &self.store,
1261 &self.repo,
1262 task_id,
1263 &[("delegations", serde_json::json!([]))],
1264 )
1265 .await
1266 {
1267 tracing::warn!(task_id, err = %e2, "failed to clear corrupt delegations in store");
1268 }
1269 if let Err(e2) = store::store_set_result(
1270 &self.store,
1271 &self.repo,
1272 task_id,
1273 &[(
1274 "last_error",
1275 serde_json::json!(format!("delegation parse failed: {e}")),
1276 )],
1277 )
1278 .await
1279 {
1280 tracing::warn!(task_id, err = %e2, "failed to write delegation_parse_failed last_error to store");
1281 }
1282 }
1283 }
1284 }
1285
1286 let summary = self.get_field(task_id, "summary").await.unwrap_or_default();
1288 let last_error = self
1289 .get_field(task_id, "last_error")
1290 .await
1291 .unwrap_or_default();
1292
1293 let is_rerouted = status == "new" || status == "routed";
1297 if is_rerouted {
1298 let silence_already_reset = store::opt_store_get_task(&self.store, &self.repo, task_id)
1304 .await
1305 .map(|t| !matches!(t.status, crate::store::TaskStatus::InProgress))
1306 .unwrap_or(false);
1307
1308 if !silence_already_reset {
1309 if let Err(e) = store::store_set_result(
1310 &self.store,
1311 &self.repo,
1312 task_id,
1313 &[
1314 ("agent", serde_json::json!("")),
1315 ("model", serde_json::json!("")),
1316 ],
1317 )
1318 .await
1319 {
1320 tracing::warn!(task_id, err = %e, "failed to clear agent/model for reroute in store");
1321 }
1322 } else {
1323 tracing::debug!(
1324 task_id,
1325 "silence detection already set fallback agent — skipping agent/model clear"
1326 );
1327 }
1328 }
1329 let is_rate_limit_error = classify_run_error_type(&last_error) == "rate_limit";
1334 let weight_signal = if is_rerouted {
1335 if is_rate_limit_error {
1336 WeightSignal::RateLimited {
1337 agent: agent_name.clone(),
1338 }
1339 } else {
1340 WeightSignal::Rerouted
1345 }
1346 } else if status == "needs_review" {
1347 let has_rate_limit_error = classify_run_error_type(&last_error) == "rate_limit";
1359 if has_rate_limit_error {
1360 if let Some(ref store) = self.store {
1362 if let Err(e) = store
1363 .record_rate_limit(&agent_name, "rate_limit", Some(task_id))
1364 .await
1365 {
1366 tracing::warn!(task_id, agent = %agent_name, err = %e, "failed to persist rate_limit event — router health check may miss this signal");
1367 }
1368 }
1369 crate::engine::cooldown::record_rate_limit(&agent_name, model, &last_error).await;
1373 WeightSignal::RateLimited {
1374 agent: agent_name.clone(),
1375 }
1376 } else {
1377 crate::engine::cooldown::record_agent_success(&agent_name, model.unwrap_or(""))
1380 .await;
1381 WeightSignal::Success {
1382 agent: agent_name.clone(),
1383 }
1384 }
1385 } else if status == "done" || status == "in_progress" || status == "in_review" {
1386 crate::engine::cooldown::record_agent_success(&agent_name, model.unwrap_or("")).await;
1388 WeightSignal::Success {
1389 agent: agent_name.clone(),
1390 }
1391 } else if status == "blocked" {
1392 WeightSignal::Blocked
1393 } else {
1394 WeightSignal::None
1395 };
1396
1397 {
1399 let attempts: u32 = self
1400 .get_field(task_id, "attempts")
1401 .await
1402 .and_then(|s| s.parse().ok())
1403 .unwrap_or(0);
1404 self.record_metrics(
1405 task_id,
1406 &agent_name,
1407 &model.map(|s| s.to_string()),
1408 &route_result.cloned(),
1409 &started_at,
1410 attempts,
1411 &status,
1412 if last_error.is_empty() {
1413 None
1414 } else {
1415 Some(&last_error)
1416 },
1417 )
1418 .await;
1419 }
1420
1421 if let Some(run_id) = run_audit_id {
1423 if let Some(ref store) = self.store {
1424 if let Err(e) = store
1425 .complete_run(&crate::store::CompleteRun {
1426 run_id,
1427 exit_code: exit_code_opt,
1428 stdout: &run_audit.stdout,
1429 stderr: &run_audit.stderr,
1430 parsed: &run_audit.parsed_response,
1431 outcome: &run_audit.outcome,
1432 error: &run_audit.error,
1433 tokens: crate::store::RunTokenUsage {
1434 input_tokens: run_audit.input_tokens,
1435 output_tokens: run_audit.output_tokens,
1436 total_cost_usd: run_audit.total_cost_usd,
1437 duration_secs: run_audit.duration_secs,
1438 },
1439 })
1440 .await
1441 {
1442 tracing::warn!(
1443 task_id,
1444 run_id,
1445 error = %e,
1446 "failed to record run completion in audit trail"
1447 );
1448 }
1449 }
1450 }
1451
1452 if is_rerouted && !is_internal_id(task_id) {
1455 let new_agent = self.get_field(task_id, "agent").await.unwrap_or_default();
1456 if !new_agent.is_empty() && new_agent != agent_name {
1457 update_agent_label_after_reroute(
1458 &self.repo,
1459 task,
1460 backend,
1461 &agent_name,
1462 &new_agent,
1463 )
1464 .await;
1465 }
1466 }
1467
1468 if is_internal_id(task_id) {
1470 return Ok(weight_signal);
1471 }
1472
1473 let new_status = match status.as_str() {
1475 "done" => Status::Done,
1476 "in_progress" => Status::InProgress,
1477 "in_review" => Status::InReview,
1478 "blocked" => Status::Blocked,
1479 "needs_review" => Status::NeedsReview,
1480 "routed" => Status::Routed, "new" => Status::New, _ => Status::NeedsReview,
1483 };
1484 if let Some(ref store) = self.store {
1486 if let Ok(Some(store_id)) = store.resolve_task_id(&self.repo, &task.id.0).await {
1487 store
1488 .update_status(
1489 store_id,
1490 crate::engine::tasks::status_to_task_status(new_status),
1491 )
1492 .await
1493 .context("store-first status update failed in runner")?;
1494 }
1495 }
1496 if let Err(e) = backend.update_status(&task.id, new_status).await {
1497 tracing::warn!(
1498 task_id,
1499 ?new_status,
1500 err = %e,
1501 "failed to mirror runner status to backend — store is authoritative"
1502 );
1503 }
1504
1505 let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
1507 let mut raw_comment = if !summary.is_empty() {
1508 format!("[{now}] {status}: {summary}")
1509 } else if !last_error.is_empty() {
1510 format!("[{now}] {status}: {last_error}")
1511 } else {
1512 format!("[{now}] {status}")
1513 };
1514
1515 let footer = crate::engine::attribution_footer("Created", &agent_name, model);
1517 raw_comment.push_str(&footer);
1518
1519 let comment = if security::has_leaks(&raw_comment) {
1521 let leaks = security::scan(&raw_comment);
1522 let rules: Vec<&str> = leaks.iter().map(|l| l.rule).collect();
1523 let warning = format!(
1524 "\n\n> ⚠️ **Security Notice**: {} potential secret(s) detected and redacted: {}",
1525 leaks.len(),
1526 rules.join(", ")
1527 );
1528 let redacted = security::redact(&raw_comment);
1529 format!("{redacted}{warning}")
1530 } else {
1531 raw_comment
1532 };
1533 backend.post_comment(&task.id, &comment).await?;
1534
1535 Ok(weight_signal)
1536 }
1537
1538 async fn process_delegations(
1544 &self,
1545 parent_task: &ExternalTask,
1546 delegations: &[crate::parser::Delegation],
1547 backend: &Arc<dyn ExternalBackend>,
1548 ) -> anyhow::Result<()> {
1549 let parent_id = &parent_task.id;
1550
1551 let existing_titles: std::collections::HashSet<String> =
1554 match backend.list_all_tasks().await {
1555 Ok(tasks) => tasks.into_iter().map(|t| t.title).collect(),
1556 Err(e) => {
1557 tracing::warn!(err = %e, "failed to fetch tasks for dedup — will create all");
1558 std::collections::HashSet::new()
1559 }
1560 };
1561
1562 for delegation in delegations {
1563 if existing_titles.contains(&delegation.title) {
1565 tracing::info!(
1566 parent = parent_id.0,
1567 title = delegation.title,
1568 "skipping delegation — issue with same title already exists"
1569 );
1570 continue;
1571 }
1572
1573 let mut labels = delegation.labels.clone();
1575 labels.push("status:new".to_string());
1576
1577 let child_body = format!(
1579 "{}\n\n---\n_Delegated from #{}_{}",
1580 delegation.body,
1581 parent_id.0,
1582 crate::engine::orch_footer()
1583 );
1584
1585 match backend
1586 .create_sub_task(parent_id, &delegation.title, &child_body, &labels)
1587 .await
1588 {
1589 Ok(child_id) => {
1590 tracing::info!(
1591 parent = parent_id.0,
1592 child = child_id.0,
1593 title = delegation.title,
1594 "created delegated subtask"
1595 );
1596 }
1597 Err(e) => {
1598 tracing::error!(
1599 parent = parent_id.0,
1600 title = delegation.title,
1601 err = %e,
1602 "failed to create delegated subtask"
1603 );
1604 }
1605 }
1606 }
1607
1608 if let Some(ref store) = self.store {
1610 if let Ok(Some(store_id)) = store.resolve_task_id(&self.repo, &parent_id.0).await {
1611 store
1612 .update_status(store_id, crate::store::TaskStatus::Blocked)
1613 .await
1614 .context("store-first: failed to block parent in store")?;
1615 }
1616 }
1617 if let Err(e) = backend.update_status(parent_id, Status::Blocked).await {
1618 tracing::warn!(
1619 parent = parent_id.0,
1620 err = %e,
1621 "failed to mirror blocked status to backend — store is authoritative"
1622 );
1623 }
1624
1625 let summary = delegations
1627 .iter()
1628 .enumerate()
1629 .map(|(i, d)| format!("{}. {}", i + 1, d.title))
1630 .collect::<Vec<_>>()
1631 .join("\n");
1632
1633 backend
1634 .post_comment(
1635 parent_id,
1636 &format!(
1637 "Delegated {} subtask(s):\n\n{}\n\nParent task is blocked until all subtasks complete.{}",
1638 delegations.len(),
1639 summary,
1640 crate::engine::orch_footer()
1641 ),
1642 )
1643 .await?;
1644
1645 Ok(())
1646 }
1647
1648 async fn resolve_project_dir(&self) -> anyhow::Result<PathBuf> {
1650 if let Ok(dir) = std::env::var("PROJECT_DIR") {
1652 if !dir.is_empty() {
1653 return Ok(PathBuf::from(dir));
1654 }
1655 }
1656
1657 if let Ok(paths) = config::get_project_paths() {
1659 for path_str in &paths {
1660 let path = PathBuf::from(path_str);
1661 if let Ok(repo) = config::get_repo_for_project(&path) {
1663 let path_for_check = path.clone();
1665 if repo == self.repo && tokio::fs::metadata(&path_for_check).await.is_ok() {
1666 return Ok(path);
1667 }
1668 }
1669 }
1670 }
1671
1672 if let Ok(dir) = config::get("project_dir") {
1674 if !dir.is_empty() {
1675 let path = PathBuf::from(&dir);
1676 let path_for_check = path.clone();
1677 if tokio::fs::metadata(&path_for_check).await.is_ok() {
1678 return Ok(path);
1679 }
1680 }
1681 }
1682
1683 let parts: Vec<&str> = self.repo.split('/').collect();
1685 if parts.len() == 2 {
1686 let bare = self
1687 .orch_home
1688 .join("projects")
1689 .join(parts[0])
1690 .join(format!("{}.git", parts[1]));
1691 let bare_for_check = bare.clone();
1692 if tokio::task::spawn_blocking(move || bare_for_check.exists())
1693 .await
1694 .unwrap_or(false)
1695 {
1696 return Ok(bare);
1697 }
1698 }
1699
1700 anyhow::bail!(
1705 "no resolvable project directory for repo {} — not in active config, no bare clone at {}, no legacy project_dir set",
1706 self.repo,
1707 self.orch_home
1708 .join("projects")
1709 .join(self.repo.split('/').next().unwrap_or_default())
1710 .join(format!(
1711 "{}.git",
1712 self.repo.split('/').nth(1).unwrap_or_default()
1713 ))
1714 .display()
1715 )
1716 }
1717}
1718
1719async fn update_agent_label_after_reroute(
1725 #[cfg_attr(test, allow(unused_variables))] repo: &str,
1726 task: &ExternalTask,
1727 backend: &Arc<dyn ExternalBackend>,
1728 old_agent: &str,
1729 new_agent: &str,
1730) -> bool {
1731 let old_label = format!("agent:{old_agent}");
1732 if let Err(e) = backend.remove_label(&task.id, &old_label).await {
1733 tracing::warn!(
1734 task_id = ?task.id,
1735 label = old_label,
1736 error = %e,
1737 "failed to remove old agent label during re-route"
1738 );
1739 }
1740 let new_label = format!("agent:{new_agent}");
1741
1742 #[cfg(not(test))]
1745 {
1746 match crate::github::http::GhHttp::new() {
1747 Ok(gh) => {
1748 if gh
1749 .ensure_label(
1750 repo,
1751 &new_label,
1752 crate::github::http::status_label_color(&new_label),
1753 &format!("Agent: {new_agent}"),
1754 )
1755 .await
1756 .is_err()
1757 {
1758 tracing::warn!(
1759 task_id = ?task.id,
1760 label = %new_label,
1761 "ensure_label failed during agent label update after failover"
1762 );
1763 }
1764 }
1765 Err(e) => {
1766 tracing::warn!(
1767 task_id = ?task.id,
1768 err = %e,
1769 "GhHttp::new() failed — skipping ensure_label"
1770 );
1771 }
1772 }
1773 }
1774
1775 if let Err(e) = backend
1776 .set_labels(&task.id, std::slice::from_ref(&new_label))
1777 .await
1778 {
1779 tracing::warn!(
1780 task_id = ?task.id,
1781 label = %new_label,
1782 error = %e,
1783 "set_labels failed during agent label update after failover"
1784 );
1785 return false;
1786 }
1787 true
1788}
1789
1790#[cfg(test)]
1791mod tests {
1792 use super::*;
1793 use crate::backends::{ExternalId, ExternalTask, Mention, Status};
1794 use crate::engine::runner::agents::patterns::safe_tail as safe_utf8_tail;
1795 use crate::parser::AgentResponse;
1796 use crate::store::{NewTask, TaskStore};
1797 use async_trait::async_trait;
1798 use chrono::Utc;
1799 use once_cell::sync::Lazy;
1800 use std::sync::{Arc, Mutex};
1801 use tempfile::TempDir;
1802
1803 static ENV_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
1804
1805 fn ok_parse_result(status: &str) -> Result<agents::ParsedResponse, agents::AgentError> {
1806 Ok(agents::ParsedResponse {
1807 response: AgentResponse {
1808 status: status.to_string(),
1809 summary: String::new(),
1810 accomplished: vec![],
1811 remaining: vec![],
1812 files: vec![],
1813 error: None,
1814 input_tokens: None,
1815 output_tokens: None,
1816 learnings: vec![],
1817 delegations: vec![],
1818 },
1819 input_tokens: None,
1820 output_tokens: None,
1821 duration_ms: None,
1822 })
1823 }
1824
1825 #[test]
1828 fn safe_utf8_tail_short_string_returned_as_is() {
1829 assert_eq!(safe_utf8_tail("hello", 100), "hello");
1830 assert_eq!(safe_utf8_tail("", 10), "");
1831 }
1832
1833 #[test]
1834 fn safe_utf8_tail_truncates_ascii() {
1835 let s = "abcdefghij"; assert_eq!(safe_utf8_tail(s, 5), "fghij");
1837 assert_eq!(safe_utf8_tail(s, 10), "abcdefghij");
1838 }
1839
1840 #[test]
1841 fn safe_utf8_tail_handles_multibyte_boundary() {
1842 let s = "日本語";
1844 assert_eq!(s.len(), 9);
1845 let tail = safe_utf8_tail(s, 8);
1849 assert!(
1850 s.ends_with(tail),
1851 "tail must be a valid suffix of the original"
1852 );
1853 assert!(
1854 std::str::from_utf8(tail.as_bytes()).is_ok(),
1855 "tail must be valid UTF-8"
1856 );
1857 }
1858
1859 #[test]
1860 fn safe_utf8_tail_handles_mixed_ascii_and_multibyte() {
1861 let s = "hello日本語world"; for max_bytes in [1, 3, 5, 7, 11, 13, 20, 100] {
1863 let tail = safe_utf8_tail(s, max_bytes);
1864 assert!(
1865 std::str::from_utf8(tail.as_bytes()).is_ok(),
1866 "max_bytes={max_bytes}: tail must be valid UTF-8"
1867 );
1868 assert!(
1869 s.ends_with(tail),
1870 "max_bytes={max_bytes}: tail must be a suffix of original"
1871 );
1872 }
1873 }
1874
1875 #[test]
1876 fn safe_utf8_tail_exact_boundary() {
1877 let s = "abcdef"; assert_eq!(safe_utf8_tail(s, 6), "abcdef"); assert_eq!(safe_utf8_tail(s, 0), ""); }
1881
1882 #[test]
1883 fn parse_success_output_synthesizes_done_from_plain_text() {
1884 let runner = agents::get_runner("claude");
1885 let parsed = parse_success_output(
1886 "123",
1887 "claude",
1888 &*runner,
1889 "No open positions, no trade executions, no conditions change.",
1890 )
1891 .unwrap();
1892
1893 assert_eq!(parsed.response.status, "done");
1894 assert_eq!(
1895 parsed.response.summary,
1896 "No open positions, no trade executions, no conditions change."
1897 );
1898 }
1899
1900 #[test]
1901 fn parse_success_output_synthesizes_needs_review_from_error_text() {
1902 let runner = agents::get_runner("claude");
1903 let parsed =
1904 parse_success_output("123", "claude", &*runner, "Failed to update branch").unwrap();
1905
1906 assert_eq!(parsed.response.status, "needs_review");
1907 assert_eq!(
1908 parsed.response.error.as_deref(),
1909 Some("agent returned plain text instead of JSON")
1910 );
1911 }
1912
1913 #[test]
1914 fn parse_success_output_keeps_empty_text_invalid() {
1915 let runner = agents::get_runner("claude");
1916 let err = parse_success_output("123", "claude", &*runner, "").unwrap_err();
1917
1918 assert!(matches!(err, agents::AgentError::InvalidResponse { .. }));
1919 }
1920
1921 #[test]
1922 fn parse_success_output_preserves_tokens_from_ndjson() {
1923 let ndjson = r#"{"type":"system","subtype":"init","session_id":"abc"}
1928{"type":"assistant","message":{"content":[{"type":"text","text":"Done."}],"usage":{"input_tokens":100,"output_tokens":50}}}
1929{"type":"result","subtype":"success","is_error":false,"duration_ms":5000,"result":"All changes committed successfully","usage":{"input_tokens":1234,"output_tokens":567}}"#;
1930 let runner = agents::get_runner("claude");
1931 let parsed = parse_success_output("456", "claude", &*runner, ndjson).unwrap();
1932
1933 assert_eq!(parsed.response.status, "done");
1934 assert_eq!(parsed.input_tokens, Some(1234));
1935 assert_eq!(parsed.output_tokens, Some(567));
1936 assert_eq!(parsed.duration_ms, Some(5000));
1937 }
1938
1939 #[test]
1940 fn parse_success_output_preserves_tokens_from_minimax_ndjson() {
1941 let ndjson = r#"{"type":"system","subtype":"init"}
1943{"type":"assistant","message":{"content":[{"type":"text","text":"No open positions."}],"usage":{"input_tokens":2000,"output_tokens":300}}}"#;
1944 let runner = agents::get_runner("minimax");
1945 let parsed = parse_success_output("789", "minimax", &*runner, ndjson).unwrap();
1946
1947 assert_eq!(parsed.response.status, "done");
1948 assert_eq!(parsed.input_tokens, Some(2000));
1949 assert_eq!(parsed.output_tokens, Some(300));
1950 }
1951
1952 #[test]
1953 fn classify_run_error_type_recognizes_specific_patterns() {
1954 let cases = [
1955 ("push failed: remote rejected", "push_failed"),
1956 ("No commits between main and branch", "no_commits"),
1957 ("create PR failed: 422", "pr_failed"),
1958 ("invalid response from agent", "parse_error"),
1959 ("parse error: missing json", "parse_error"),
1960 ("exceeded max attempts while rerouting", "max_attempts"),
1961 ("unit test failed", "ci_failure"),
1962 ("build failed: type mismatch", "ci_failure"),
1963 ("lint failed on warning", "ci_failure"),
1964 ("3 tests failed", "ci_failure"),
1965 ("test suite failed: 3 tests failed", "ci_failure"),
1966 ];
1967
1968 for (input, expected) in cases {
1969 assert_eq!(classify_run_error_type(input), expected, "input={input}");
1970 }
1971 }
1972
1973 #[test]
1974 fn classify_run_error_type_falls_back_for_unknown_errors() {
1975 assert_eq!(classify_run_error_type("something unexpected"), "failed");
1976 }
1977
1978 #[test]
1979 fn classify_run_error_type_connection_error_mentioning_pull_request_is_not_pr_failed() {
1980 assert_eq!(
1983 classify_run_error_type("failed to fetch pull request details from GitHub"),
1984 "failed"
1985 );
1986 assert_eq!(
1987 classify_run_error_type("connection reset while reading pull request data"),
1988 "failed"
1989 );
1990 }
1991
1992 #[test]
1993 fn classify_run_error_type_url_with_test_substring_is_not_ci_failure() {
1994 assert_eq!(
1996 classify_run_error_type(
1997 "failed to connect to api.github.com/repos/test-org/repo/pulls/123"
1998 ),
1999 "failed"
2000 );
2001 assert_eq!(
2003 classify_run_error_type("context deadline exceeded testing connection"),
2004 "failed"
2005 );
2006 }
2007
2008 #[test]
2009 fn classify_run_error_type_usage_without_limit_is_not_rate_limit() {
2010 assert_eq!(
2012 classify_run_error_type("token usage: 1245 tokens"),
2013 "failed"
2014 );
2015 assert_eq!(
2016 classify_run_error_type("incorrect usage of git command"),
2017 "failed"
2018 );
2019 assert_eq!(
2021 classify_run_error_type("exceeded usage limit for this billing period"),
2022 "rate_limit"
2023 );
2024 }
2025
2026 #[test]
2027 fn classify_run_error_type_billing_cycle_is_not_auth_error() {
2028 assert_eq!(
2031 classify_run_error_type(
2032 "You've reached your usage limit for this billing cycle. \
2033 Your quota will be refreshed in the next cycle."
2034 ),
2035 "billing_cycle_exhausted"
2036 );
2037 assert_eq!(
2038 classify_run_error_type("quota exhausted for billing cycle"),
2039 "billing_cycle_exhausted"
2040 );
2041 assert_eq!(
2043 classify_run_error_type("billing account suspended"),
2044 "failed"
2045 );
2046 }
2047
2048 #[test]
2049 fn classify_run_error_type_monthly_quota_is_billing_cycle() {
2050 assert_eq!(
2053 classify_run_error_type(
2054 "opencode failed: agent failed: You have exceeded your monthly quota"
2055 ),
2056 "billing_cycle_exhausted"
2057 );
2058 assert_eq!(
2059 classify_run_error_type("You have exceeded your monthly quota"),
2060 "billing_cycle_exhausted"
2061 );
2062 }
2063
2064 #[test]
2065 fn classify_run_outcome_routed_is_success() {
2066 let parse_result = ok_parse_result("routed");
2070 assert_eq!(
2071 classify_run_outcome("routed", &parse_result, false),
2072 "success"
2073 );
2074 }
2075
2076 #[test]
2077 fn classify_run_outcome_blocked_is_blocked() {
2078 let parse_result = ok_parse_result("blocked");
2079 assert_eq!(
2080 classify_run_outcome("blocked", &parse_result, false),
2081 "blocked"
2082 );
2083 }
2084
2085 #[test]
2086 fn classify_run_outcome_non_canonical_is_failed() {
2087 let parse_result = ok_parse_result("fix_deployed");
2090 assert_eq!(
2091 classify_run_outcome("fix_deployed", &parse_result, false),
2092 "failed"
2093 );
2094 }
2095
2096 #[test]
2097 fn classify_run_outcome_kimi_billing_cycle_is_not_rate_limit() {
2098 let parse_result: Result<agents::ParsedResponse, agents::AgentError> =
2103 Err(agents::AgentError::RateLimit {
2104 message: "Failed to authenticate. API Error: 403 You've reached your usage \
2105 limit for this billing cycle. Your quota will be refreshed soon."
2106 .to_string(),
2107 });
2108 assert_eq!(
2109 classify_run_outcome("needs_review", &parse_result, false),
2110 "billing_cycle_exhausted"
2111 );
2112 }
2113
2114 #[test]
2115 fn classify_run_outcome_generic_rate_limit_is_unaffected() {
2116 let parse_result: Result<agents::ParsedResponse, agents::AgentError> =
2119 Err(agents::AgentError::RateLimit {
2120 message: "429 Too Many Requests".to_string(),
2121 });
2122 assert_eq!(
2123 classify_run_outcome("needs_review", &parse_result, false),
2124 "rate_limit"
2125 );
2126 }
2127
2128 #[test]
2129 fn classify_run_outcome_auth_billing_cycle_is_not_failed() {
2130 let parse_result: Result<agents::ParsedResponse, agents::AgentError> =
2134 Err(agents::AgentError::Auth {
2135 message: "billing cycle exhausted for this account".to_string(),
2136 });
2137 assert_eq!(
2138 classify_run_outcome("needs_review", &parse_result, false),
2139 "billing_cycle_exhausted"
2140 );
2141 }
2142
2143 #[test]
2144 fn classify_run_outcome_generic_auth_error_is_unaffected() {
2145 let parse_result: Result<agents::ParsedResponse, agents::AgentError> =
2146 Err(agents::AgentError::Auth {
2147 message: "401 Unauthorized".to_string(),
2148 });
2149 assert_eq!(
2150 classify_run_outcome("needs_review", &parse_result, false),
2151 "failed"
2152 );
2153 }
2154
2155 #[tokio::test]
2156 async fn build_run_audit_success_ignores_stale_last_error() {
2157 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2158 let task_id = store
2159 .create(&NewTask {
2160 repo: "owner/repo".to_string(),
2161 origin: "internal".to_string(),
2162 title: "Test".to_string(),
2163 ..Default::default()
2164 })
2165 .await
2166 .unwrap();
2167 store
2168 .set_fields(
2169 task_id,
2170 &[("last_error", serde_json::json!("no error info available"))],
2171 )
2172 .await
2173 .unwrap();
2174
2175 let runner = TaskRunner::new("owner/repo".to_string()).with_store(store);
2176 let parse_result = ok_parse_result("done");
2177 let started_at = Utc::now();
2178 let audit = runner
2179 .build_run_audit(RunAuditInput {
2180 task_id: &task_id.to_string(),
2181 status: "done",
2182 parse_result: &parse_result,
2183 raw_stdout: "",
2184 raw_stderr: "",
2185 started_at: &started_at,
2186 error_override: None,
2187 elapsed_secs: Some(1),
2188 push_failed: false,
2189 })
2190 .await;
2191
2192 assert_eq!(audit.outcome, "success");
2193 assert!(audit.error.is_empty());
2194 }
2195
2196 #[tokio::test]
2197 async fn build_run_audit_non_success_uses_fallback_error_when_empty() {
2198 let runner = TaskRunner::new("owner/repo".to_string());
2199 let parse_result = ok_parse_result("new");
2200 let started_at = Utc::now();
2201 let audit = runner
2202 .build_run_audit(RunAuditInput {
2203 task_id: "1",
2204 status: "new",
2205 parse_result: &parse_result,
2206 raw_stdout: "",
2207 raw_stderr: "",
2208 started_at: &started_at,
2209 error_override: None,
2210 elapsed_secs: Some(1),
2211 push_failed: false,
2212 })
2213 .await;
2214
2215 assert_eq!(audit.outcome, "failed");
2216 assert_eq!(audit.error, "");
2218 }
2219
2220 #[tokio::test]
2221 async fn build_run_audit_non_canonical_status_reports_explicit_error() {
2222 let runner = TaskRunner::new("owner/repo".to_string());
2226 let parse_result = ok_parse_result("fix_deployed");
2227 let started_at = Utc::now();
2228 let audit = runner
2229 .build_run_audit(RunAuditInput {
2230 task_id: "1",
2231 status: "fix_deployed",
2232 parse_result: &parse_result,
2233 raw_stdout: "",
2234 raw_stderr: "",
2235 started_at: &started_at,
2236 error_override: None,
2237 elapsed_secs: Some(1),
2238 push_failed: false,
2239 })
2240 .await;
2241
2242 assert_eq!(audit.outcome, "failed");
2243 assert_eq!(audit.error, "unrecognized status: fix_deployed");
2244 }
2245
2246 #[tokio::test]
2247 async fn build_run_audit_canonical_non_success_status_no_error_message() {
2248 for status in &["routed"] {
2251 let runner = TaskRunner::new("owner/repo".to_string());
2252 let parse_result = ok_parse_result(status);
2253 let started_at = Utc::now();
2254 let audit = runner
2255 .build_run_audit(RunAuditInput {
2256 task_id: "1",
2257 status,
2258 parse_result: &parse_result,
2259 raw_stdout: "",
2260 raw_stderr: "",
2261 started_at: &started_at,
2262 error_override: None,
2263 elapsed_secs: Some(1),
2264 push_failed: false,
2265 })
2266 .await;
2267
2268 assert_eq!(audit.outcome, "success", "status={status}");
2269 assert_eq!(
2270 audit.error, "",
2271 "status={status}: no error for canonical status"
2272 );
2273 }
2274 }
2275
2276 #[tokio::test]
2277 async fn build_run_audit_blocked_uses_summary_when_error_missing() {
2278 let runner = TaskRunner::new("owner/repo".to_string());
2279 let parse_result = Ok(agents::ParsedResponse {
2280 response: AgentResponse {
2281 status: "blocked".to_string(),
2282 summary: "Could not proceed due to missing credentials".to_string(),
2283 accomplished: vec![],
2284 remaining: vec![],
2285 files: vec![],
2286 error: None,
2287 input_tokens: None,
2288 output_tokens: None,
2289 learnings: vec![],
2290 delegations: vec![],
2291 },
2292 input_tokens: None,
2293 output_tokens: None,
2294 duration_ms: None,
2295 });
2296 let started_at = Utc::now();
2297 let audit = runner
2298 .build_run_audit(RunAuditInput {
2299 task_id: "1",
2300 status: "blocked",
2301 parse_result: &parse_result,
2302 raw_stdout: "",
2303 raw_stderr: "",
2304 started_at: &started_at,
2305 error_override: None,
2306 elapsed_secs: Some(1),
2307 push_failed: false,
2308 })
2309 .await;
2310
2311 assert_eq!(audit.outcome, "blocked");
2312 assert_eq!(audit.error, "Could not proceed due to missing credentials");
2313 }
2314
2315 #[tokio::test]
2318 async fn resolve_project_dir_uses_project_dir_env() {
2319 let runner = TaskRunner::new("owner/repo".to_string());
2321
2322 let dir = tempfile::tempdir().unwrap();
2324 let dir_str = dir.path().to_string_lossy().to_string();
2325
2326 std::env::set_var("PROJECT_DIR", &dir_str);
2331 let result = runner.resolve_project_dir().await;
2332 std::env::remove_var("PROJECT_DIR");
2333
2334 assert!(
2335 result.is_ok(),
2336 "resolve_project_dir should succeed with PROJECT_DIR set"
2337 );
2338 assert_eq!(result.unwrap(), dir.path());
2339 }
2340
2341 #[tokio::test]
2342 async fn resolve_project_dir_empty_project_dir_env_falls_through() {
2343 let runner = TaskRunner::new("owner/testrepo-nonexistent".to_string());
2345 std::env::set_var("PROJECT_DIR", "");
2346 let result = runner.resolve_project_dir().await;
2347 std::env::remove_var("PROJECT_DIR");
2348
2349 assert!(
2353 result.is_err(),
2354 "resolve_project_dir should error when no project dir can be resolved"
2355 );
2356 let err = result.unwrap_err().to_string();
2357 assert!(
2358 err.contains("owner/testrepo-nonexistent"),
2359 "error should name the unresolvable repo: {err}"
2360 );
2361 }
2362
2363 struct TrackingBackend {
2366 sub_tasks_created: Arc<Mutex<Vec<(String, String)>>>, status_updates: Arc<Mutex<Vec<(String, Status)>>>,
2368 comments: Arc<Mutex<Vec<(String, String)>>>, }
2370
2371 impl TrackingBackend {
2372 fn new() -> Self {
2373 Self {
2374 sub_tasks_created: Arc::new(Mutex::new(vec![])),
2375 status_updates: Arc::new(Mutex::new(vec![])),
2376 comments: Arc::new(Mutex::new(vec![])),
2377 }
2378 }
2379 }
2380
2381 #[async_trait]
2382 impl crate::backends::ExternalBackend for TrackingBackend {
2383 fn name(&self) -> &str {
2384 "tracking"
2385 }
2386 async fn create_task(
2387 &self,
2388 _t: &str,
2389 _b: &str,
2390 _l: &[String],
2391 ) -> anyhow::Result<ExternalId> {
2392 Ok(ExternalId("new".to_string()))
2393 }
2394 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
2395 Ok(ExternalTask {
2396 id: id.clone(),
2397 title: "".to_string(),
2398 body: "".to_string(),
2399 state: "open".to_string(),
2400 labels: vec![],
2401 author: "".to_string(),
2402 created_at: "2026-01-01T00:00:00Z".to_string(),
2403 updated_at: "2026-01-01T00:00:00Z".to_string(),
2404 url: "".to_string(),
2405 })
2406 }
2407 async fn list_by_status(&self, _s: Status) -> anyhow::Result<Vec<ExternalTask>> {
2408 Ok(vec![])
2409 }
2410 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2411 Ok(vec![])
2412 }
2413 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
2414 self.comments
2415 .lock()
2416 .unwrap()
2417 .push((id.0.clone(), body.to_string()));
2418 Ok(())
2419 }
2420 async fn set_labels(&self, _id: &ExternalId, _l: &[String]) -> anyhow::Result<()> {
2421 Ok(())
2422 }
2423 async fn remove_label(&self, _id: &ExternalId, _l: &str) -> anyhow::Result<()> {
2424 Ok(())
2425 }
2426 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2427 Ok(vec![])
2428 }
2429 async fn create_sub_task(
2430 &self,
2431 parent: &ExternalId,
2432 title: &str,
2433 _body: &str,
2434 _l: &[String],
2435 ) -> anyhow::Result<ExternalId> {
2436 self.sub_tasks_created
2437 .lock()
2438 .unwrap()
2439 .push((title.to_string(), parent.0.clone()));
2440 Ok(ExternalId(format!("child-{}", title)))
2441 }
2442 async fn ensure_status_label(&self, _l: &str) -> anyhow::Result<()> {
2443 Ok(())
2444 }
2445 async fn has_open_issue_with_title(&self, _t: &str, _l: &str) -> anyhow::Result<bool> {
2446 Ok(false)
2447 }
2448 async fn health_check(&self) -> anyhow::Result<()> {
2449 Ok(())
2450 }
2451 async fn is_pr_merged(&self, _b: &str) -> anyhow::Result<bool> {
2452 Ok(false)
2453 }
2454 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
2455 Ok(None)
2456 }
2457 async fn get_mentions(&self, _s: &str) -> anyhow::Result<Vec<Mention>> {
2458 Ok(vec![])
2459 }
2460 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
2461 self.status_updates
2462 .lock()
2463 .unwrap()
2464 .push((id.0.clone(), status));
2465 Ok(())
2466 }
2467 }
2468
2469 fn make_task(id: &str) -> ExternalTask {
2470 ExternalTask {
2471 id: ExternalId(id.to_string()),
2472 title: format!("Task {id}"),
2473 body: "".to_string(),
2474 state: "open".to_string(),
2475 labels: vec![],
2476 author: "bot".to_string(),
2477 created_at: "2026-01-01T00:00:00Z".to_string(),
2478 updated_at: "2026-01-01T00:00:00Z".to_string(),
2479 url: "".to_string(),
2480 }
2481 }
2482
2483 #[allow(clippy::await_holding_lock)]
2486 #[tokio::test]
2487 async fn process_delegations_creates_subtasks_and_blocks_parent() {
2488 let _guard = ENV_LOCK.lock().unwrap();
2489 let temp_home = TempDir::new().unwrap();
2490 let orch_home = temp_home.path().join(".orch");
2491 tokio::fs::create_dir_all(&orch_home).await.unwrap();
2492 let old_orch_home = std::env::var("ORCH_HOME").ok();
2493 std::env::set_var("ORCH_HOME", &orch_home);
2494
2495 let runner = TaskRunner::new("owner/repo".to_string());
2496 let parent = make_task("99");
2497 let backend = Arc::new(TrackingBackend::new());
2498 let backend_dyn: Arc<dyn crate::backends::ExternalBackend> = backend.clone();
2499
2500 let delegations = vec![
2501 crate::parser::Delegation {
2502 title: "Subtask A".to_string(),
2503 body: "Do A".to_string(),
2504 labels: vec!["enhancement".to_string()],
2505 },
2506 crate::parser::Delegation {
2507 title: "Subtask B".to_string(),
2508 body: "Do B".to_string(),
2509 labels: vec![],
2510 },
2511 ];
2512
2513 runner
2514 .process_delegations(&parent, &delegations, &backend_dyn)
2515 .await
2516 .unwrap();
2517
2518 let created = backend.sub_tasks_created.lock().unwrap();
2520 assert_eq!(created.len(), 2);
2521 assert_eq!(created[0].1, "99", "parent id should be 99");
2522 assert!(
2523 created.iter().any(|(t, _)| t == "Subtask A"),
2524 "Subtask A should be created"
2525 );
2526 assert!(
2527 created.iter().any(|(t, _)| t == "Subtask B"),
2528 "Subtask B should be created"
2529 );
2530 drop(created);
2531
2532 let updates = backend.status_updates.lock().unwrap();
2534 assert!(
2535 updates
2536 .iter()
2537 .any(|(id, s)| id == "99" && *s == Status::Blocked),
2538 "parent should be blocked"
2539 );
2540 drop(updates);
2541
2542 let comments = backend.comments.lock().unwrap();
2544 assert!(
2545 !comments.is_empty(),
2546 "a delegation summary comment should be posted"
2547 );
2548 let comment_body = &comments[0].1;
2549 assert!(
2550 comment_body.contains("Subtask A"),
2551 "summary should mention Subtask A"
2552 );
2553 assert!(
2554 comment_body.contains("Subtask B"),
2555 "summary should mention Subtask B"
2556 );
2557
2558 if let Some(old) = old_orch_home {
2559 std::env::set_var("ORCH_HOME", old);
2560 } else {
2561 std::env::remove_var("ORCH_HOME");
2562 }
2563 }
2564
2565 #[allow(clippy::await_holding_lock)]
2566 #[tokio::test]
2567 async fn process_delegations_single_subtask() {
2568 let _guard = ENV_LOCK.lock().unwrap();
2569 let temp_home = TempDir::new().unwrap();
2570 let orch_home = temp_home.path().join(".orch");
2571 tokio::fs::create_dir_all(&orch_home).await.unwrap();
2572 let old_orch_home = std::env::var("ORCH_HOME").ok();
2573 std::env::set_var("ORCH_HOME", &orch_home);
2574
2575 let runner = TaskRunner::new("owner/repo".to_string());
2576 let parent = make_task("101");
2577 let backend = Arc::new(TrackingBackend::new());
2578 let backend_dyn: Arc<dyn crate::backends::ExternalBackend> = backend.clone();
2579
2580 let delegations = vec![crate::parser::Delegation {
2581 title: "Only Child".to_string(),
2582 body: "Do only child".to_string(),
2583 labels: vec!["feature".to_string()],
2584 }];
2585
2586 runner
2587 .process_delegations(&parent, &delegations, &backend_dyn)
2588 .await
2589 .unwrap();
2590
2591 let created = backend.sub_tasks_created.lock().unwrap();
2592 assert_eq!(created.len(), 1);
2593 assert_eq!(created[0].0, "Only Child");
2594 drop(created);
2595
2596 let updates = backend.status_updates.lock().unwrap();
2597 assert!(
2598 updates
2599 .iter()
2600 .any(|(id, s)| id == "101" && *s == Status::Blocked),
2601 "parent should be blocked"
2602 );
2603 drop(updates);
2604
2605 let comments = backend.comments.lock().unwrap();
2606 let body = &comments[0].1;
2607 assert!(
2608 body.contains("1 subtask"),
2609 "comment should count one subtask"
2610 );
2611
2612 if let Some(old) = old_orch_home {
2613 std::env::set_var("ORCH_HOME", old);
2614 } else {
2615 std::env::remove_var("ORCH_HOME");
2616 }
2617 }
2618
2619 fn weight_signal_for(status: &str, error_type: &str, agent: &str) -> WeightSignal {
2624 let is_rerouted = status == "new" || status == "routed";
2625 let is_rate_limit_error = error_type == "rate_limit";
2626 if is_rerouted {
2627 if is_rate_limit_error {
2628 WeightSignal::RateLimited {
2629 agent: agent.to_string(),
2630 }
2631 } else {
2632 WeightSignal::Rerouted
2633 }
2634 } else if status == "done"
2635 || status == "needs_review"
2636 || status == "in_progress"
2637 || status == "in_review"
2638 {
2639 WeightSignal::Success {
2640 agent: agent.to_string(),
2641 }
2642 } else if status == "blocked" {
2643 WeightSignal::Blocked
2644 } else {
2645 WeightSignal::None
2646 }
2647 }
2648
2649 #[test]
2650 fn weight_signal_success_includes_needs_review() {
2651 let signal = weight_signal_for("needs_review", "success", "claude");
2654 assert!(
2655 matches!(signal, WeightSignal::Success { agent } if agent == "claude"),
2656 "needs_review should produce WeightSignal::Success"
2657 );
2658 }
2659
2660 #[test]
2661 fn weight_signal_success_for_all_done_statuses() {
2662 for status in ["done", "needs_review", "in_progress", "in_review"] {
2663 let signal = weight_signal_for(status, "success", "codex");
2664 assert!(
2665 matches!(signal, WeightSignal::Success { agent } if agent == "codex"),
2666 "{status} should produce WeightSignal::Success"
2667 );
2668 }
2669 }
2670
2671 #[test]
2672 fn weight_signal_rate_limited_for_reroute_with_rate_limit_error() {
2673 let signal = weight_signal_for("new", "rate_limit", "claude");
2675 assert!(matches!(signal, WeightSignal::RateLimited { agent } if agent == "claude"));
2676 }
2677
2678 #[test]
2679 fn weight_signal_rate_limited_for_routed_status_with_rate_limit_error() {
2680 let signal = weight_signal_for("routed", "rate_limit", "claude");
2681 assert!(matches!(signal, WeightSignal::RateLimited { agent } if agent == "claude"));
2682 }
2683
2684 #[test]
2685 fn weight_signal_rerouted_for_non_rate_limit_reroute() {
2686 for error_type in ["timeout", "silence detected", "parse_error", "failed"] {
2691 for status in ["new", "routed"] {
2692 let signal = weight_signal_for(status, error_type, "claude");
2693 assert!(
2694 matches!(signal, WeightSignal::Rerouted),
2695 "{status} with error_type={error_type} should produce WeightSignal::Rerouted, not RateLimited or None"
2696 );
2697 }
2698 }
2699 }
2700
2701 #[test]
2702 fn silence_reset_rerouted_never_becomes_needs_review() {
2703 let signal = weight_signal_for("new", "silence detected", "claude");
2708 assert!(
2709 matches!(signal, WeightSignal::Rerouted),
2710 "silence-reset reroute must produce Rerouted, never None/needs_review"
2711 );
2712 let display_status = match &signal {
2714 WeightSignal::Rerouted => "new",
2715 WeightSignal::None => "needs_review", _ => "other",
2717 };
2718 assert_eq!(
2719 display_status, "new",
2720 "Rerouted must map to 'new' in tick, not 'needs_review'"
2721 );
2722 }
2723
2724 #[test]
2725 fn weight_signal_blocked_status() {
2726 let signal = weight_signal_for("blocked", "success", "claude");
2727 assert!(matches!(signal, WeightSignal::Blocked));
2728 }
2729
2730 struct FailingMockRunner;
2735
2736 impl agents::AgentRunner for FailingMockRunner {
2737 #[cfg(test)]
2738 fn name(&self) -> &str {
2739 "opencode"
2740 }
2741
2742 fn stream_events(&self, _event: &serde_json::Value) -> Vec<agents::StreamEvent> {
2743 vec![]
2744 }
2745
2746 fn build_command(
2747 &self,
2748 _model: Option<&str>,
2749 _timeout_cmd: &str,
2750 _sys_file: &str,
2751 _msg_file: &str,
2752 _permissions: &agents::PermissionRules,
2753 ) -> String {
2754 String::new()
2755 }
2756
2757 fn parse_response(&self, raw: &str) -> Result<agents::ParsedResponse, agents::AgentError> {
2758 Err(agents::AgentError::InvalidResponse {
2760 raw: raw.chars().take(30).collect(),
2761 })
2762 }
2763
2764 fn extract_text(&self, raw: &str) -> Result<String, agents::AgentError> {
2765 Err(agents::AgentError::InvalidResponse {
2766 raw: raw.to_string(),
2767 })
2768 }
2769
2770 fn classify_error(
2771 &self,
2772 exit_code: i32,
2773 _stdout: &str,
2774 _stderr: &str,
2775 ) -> agents::AgentError {
2776 agents::AgentError::Unknown {
2777 exit_code,
2778 message: "mock error".into(),
2779 }
2780 }
2781
2782 fn router_command(
2783 &self,
2784 _prompt: &str,
2785 _model: Option<&str>,
2786 ) -> anyhow::Result<tokio::process::Command> {
2787 anyhow::bail!("not implemented for mock")
2788 }
2789 }
2790
2791 #[test]
2792 fn parse_success_output_extracts_tokens_from_raw_stdout_not_error_raw() {
2793 let ndjson = r#"{"type":"text","timestamp":1,"part":{"type":"text","text":"Here is my response: {\"status\":\"done\",\"summary\":\"fixed\"}}"}}
2795{"type":"step_finish","timestamp":2,"part":{"type":"step-finish","reason":"stop","cost":0,"tokens":{"total":95177,"input":94920,"output":257}}}"#;
2796
2797 let runner = FailingMockRunner;
2798 let result = parse_success_output("1550", "opencode", &runner, ndjson);
2799
2800 let parsed = result.expect("should synthesize response from text");
2802 assert_eq!(parsed.input_tokens, Some(94920));
2803 assert_eq!(parsed.output_tokens, Some(257));
2804 }
2805
2806 #[test]
2807 fn parse_success_output_no_tokens_when_ndjson_has_none() {
2808 let text = "No open positions, no trade executions, no conditions change.";
2811
2812 let runner = FailingMockRunner;
2813 let result = parse_success_output("1551", "opencode", &runner, text);
2814
2815 let parsed = result.expect("should synthesize response from explicit-done text");
2817 assert_eq!(parsed.input_tokens, None);
2818 assert_eq!(parsed.output_tokens, None);
2819 }
2820
2821 #[test]
2822 fn parse_success_output_uses_agent_result_directly() {
2823 let ndjson = r#"{"type":"result","subtype":"success","is_error":false,"result":"{\"status\":\"done\",\"summary\":\"my summary\",\"accomplished\":[],\"remaining\":[],\"files\":[]}"}"#;
2826
2827 let runner = FailingMockRunner;
2829 let result = parse_success_output("999", "claude", &runner, ndjson);
2830
2831 let parsed = result.expect("should parse successfully via find_agent_result");
2832 assert_eq!(parsed.response.status, "done");
2833 assert_eq!(parsed.response.summary, "my summary");
2834 }
2835
2836 #[test]
2837 fn parse_success_output_treats_agent_result_is_error_as_authoritative() {
2838 let ndjson = r#"{"type":"result","subtype":"success","is_error":true,"result":"I failed because of reasons"}"#;
2841
2842 let runner = FailingMockRunner;
2843 let result = parse_success_output("999", "claude", &runner, ndjson);
2844
2845 let err = result.expect_err("should return error");
2846 match err {
2847 agents::AgentError::AgentFailed { message } => {
2848 assert_eq!(message, "I failed because of reasons");
2849 }
2850 _ => panic!("expected AgentFailed, got {err:?}"),
2851 }
2852 }
2853
2854 #[test]
2855 fn parse_success_output_classifies_model_unavailable_from_is_error_result() {
2856 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.)"}"#;
2863
2864 let runner = FailingMockRunner;
2865 let result = parse_success_output("999", "claude", &runner, ndjson);
2866
2867 let err = result.expect_err("should return error");
2868 match err {
2869 agents::AgentError::ModelUnavailable { model, .. } => {
2870 assert_eq!(model, "gpt-5.5");
2871 }
2872 _ => panic!("expected ModelUnavailable, got {err:?}"),
2873 }
2874 }
2875
2876 #[test]
2877 fn nonzero_exit_with_ndjson_success_is_parsed_as_success() {
2878 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}}"#;
2882
2883 let runner = FailingMockRunner;
2887
2888 let session_output = session::SessionOutput {
2890 exit_code: 1,
2891 raw_stdout: ndjson.to_string(),
2892 raw_stderr: String::new(),
2893 elapsed_secs: Some(1),
2894 };
2895
2896 let parsed =
2899 parse_success_output("task-ndjson", "claude", &runner, &session_output.raw_stdout)
2900 .expect("should parse NDJSON success");
2901 assert_eq!(parsed.response.status, "done");
2902 assert_eq!(parsed.response.summary, "All good");
2903 assert_eq!(parsed.input_tokens, Some(10));
2904 assert_eq!(parsed.output_tokens, Some(5));
2905 }
2906
2907 struct LabelFailingBackend {
2911 inner: TrackingBackend,
2912 set_labels_err: Option<String>,
2914 }
2915
2916 impl LabelFailingBackend {
2917 fn new() -> Self {
2918 Self {
2919 inner: TrackingBackend::new(),
2920 set_labels_err: None,
2921 }
2922 }
2923 fn with_set_labels_err(mut self, err: impl Into<String>) -> Self {
2924 self.set_labels_err = Some(err.into());
2925 self
2926 }
2927 }
2928
2929 impl Default for LabelFailingBackend {
2930 fn default() -> Self {
2931 Self::new()
2932 }
2933 }
2934
2935 #[async_trait]
2936 impl crate::backends::ExternalBackend for LabelFailingBackend {
2937 fn name(&self) -> &str {
2938 "label_failing"
2939 }
2940 async fn create_task(
2941 &self,
2942 _t: &str,
2943 _b: &str,
2944 _l: &[String],
2945 ) -> anyhow::Result<ExternalId> {
2946 Ok(ExternalId("new".to_string()))
2947 }
2948 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
2949 self.inner.get_task(id).await
2950 }
2951 async fn list_by_status(&self, _s: Status) -> anyhow::Result<Vec<ExternalTask>> {
2952 Ok(vec![])
2953 }
2954 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2955 Ok(vec![])
2956 }
2957 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()> {
2958 self.inner.post_comment(id, body).await
2959 }
2960 async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
2961 if let Some(ref err) = self.set_labels_err {
2962 return Err(anyhow::anyhow!("{}", err));
2963 }
2964 self.inner.set_labels(id, labels).await
2965 }
2966 async fn remove_label(&self, id: &ExternalId, label: &str) -> anyhow::Result<()> {
2967 self.inner.remove_label(id, label).await
2968 }
2969 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2970 Ok(vec![])
2971 }
2972 async fn create_sub_task(
2973 &self,
2974 parent: &ExternalId,
2975 title: &str,
2976 body: &str,
2977 labels: &[String],
2978 ) -> anyhow::Result<ExternalId> {
2979 self.inner
2980 .create_sub_task(parent, title, body, labels)
2981 .await
2982 }
2983 async fn ensure_status_label(&self, label: &str) -> anyhow::Result<()> {
2984 self.inner.ensure_status_label(label).await
2985 }
2986 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
2987 self.inner.update_status(id, status).await
2988 }
2989 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
2990 self.inner.get_authenticated_user().await
2991 }
2992 async fn get_mentions(&self, _s: &str) -> anyhow::Result<Vec<Mention>> {
2993 Ok(vec![])
2994 }
2995 async fn health_check(&self) -> anyhow::Result<()> {
2996 self.inner.health_check().await
2997 }
2998 }
2999
3000 #[tokio::test]
3005 async fn update_agent_label_returns_false_on_set_labels_failure() {
3006 let task = ExternalTask {
3007 id: ExternalId("test-reroute-1".to_string()),
3008 title: "Test".to_string(),
3009 body: "body".to_string(),
3010 state: "open".to_string(),
3011 labels: vec![],
3012 author: "bot".to_string(),
3013 created_at: "2026-01-01T00:00:00Z".to_string(),
3014 updated_at: "2026-01-01T00:00:00Z".to_string(),
3015 url: "".to_string(),
3016 };
3017 let backend: Arc<dyn crate::backends::ExternalBackend> =
3018 Arc::new(LabelFailingBackend::new().with_set_labels_err("network error"));
3019
3020 let result =
3021 update_agent_label_after_reroute("owner/repo", &task, &backend, "claude", "codex")
3022 .await;
3023
3024 assert!(
3025 !result,
3026 "update_agent_label_after_reroute should return false when set_labels fails"
3027 );
3028 }
3029
3030 #[tokio::test]
3032 async fn update_agent_label_returns_true_on_success() {
3033 let task = ExternalTask {
3034 id: ExternalId("test-reroute-2".to_string()),
3035 title: "Test".to_string(),
3036 body: "body".to_string(),
3037 state: "open".to_string(),
3038 labels: vec![],
3039 author: "bot".to_string(),
3040 created_at: "2026-01-01T00:00:00Z".to_string(),
3041 updated_at: "2026-01-01T00:00:00Z".to_string(),
3042 url: "".to_string(),
3043 };
3044 let backend: Arc<dyn crate::backends::ExternalBackend> =
3046 Arc::new(LabelFailingBackend::new());
3047
3048 let result =
3049 update_agent_label_after_reroute("owner/repo", &task, &backend, "claude", "codex")
3050 .await;
3051
3052 assert!(
3054 result,
3055 "update_agent_label_after_reroute should return true when set_labels succeeds"
3056 );
3057 }
3058
3059 #[test]
3065 fn parse_success_output_ndjson_envelope_fallback_domain_json_result() {
3066 let domain_result = r#"{"file_path":"md/journal/2026-05-08-morning-briefing.md","summary":"Morning briefing filed","health":{"status":"ok"},"priorities":[]}"#;
3067 let ndjson = format!(
3068 r#"{{"type":"result","subtype":"success","is_error":false,"terminal_reason":"completed","result":{domain_result:?},"usage":{{"input_tokens":3000,"output_tokens":400}}}}"#
3069 );
3070
3071 let runner = agents::get_runner("glm");
3072 let parsed = parse_success_output("149200", "glm", &*runner, &ndjson)
3073 .expect("should synthesize done from NDJSON envelope when result is domain JSON");
3074
3075 assert_eq!(parsed.response.status, "done");
3076 assert!(
3077 parsed.response.summary.contains("file_path") || !parsed.response.summary.is_empty(),
3078 "summary should be populated from domain JSON result text"
3079 );
3080 assert_eq!(parsed.input_tokens, Some(3000));
3081 assert_eq!(parsed.output_tokens, Some(400));
3082 }
3083
3084 #[test]
3086 fn parse_success_output_envelope_fallback_without_terminal_reason() {
3087 let receipt = r#"{"ok":true,"report":"notes.md","commit":"abc"}"#;
3088 let ndjson = format!(
3089 r#"{{"type":"result","subtype":"success","is_error":false,"stop_reason":"end_turn","result":{receipt:?},"usage":{{"input_tokens":3000,"output_tokens":400}}}}"#
3090 );
3091 let runner = agents::get_runner("grok");
3092 let parsed = parse_success_output("1", "grok", &*runner, &ndjson).expect("done");
3093 assert_eq!(parsed.response.status, "done");
3094 assert_eq!(parsed.input_tokens, Some(3000));
3095 }
3096
3097 #[test]
3101 fn parse_success_output_ndjson_envelope_fallback_plain_prose_result() {
3102 let prose = "PR #1008 now has zero diff vs main. The reverted ledger changes are gone and the branch is clean.";
3103 let ndjson = format!(
3104 r#"{{"type":"result","subtype":"success","is_error":false,"terminal_reason":"completed","result":{prose:?},"usage":{{"input_tokens":2500,"output_tokens":300}}}}"#
3105 );
3106
3107 let runner = agents::get_runner("claude");
3108 let parsed = parse_success_output("149164", "claude", &*runner, &ndjson)
3109 .expect("should synthesize done from NDJSON envelope when result is plain prose");
3110
3111 assert_eq!(parsed.response.status, "done");
3112 assert_eq!(parsed.response.summary, prose);
3113 assert_eq!(parsed.input_tokens, Some(2500));
3114 assert_eq!(parsed.output_tokens, Some(300));
3115 }
3116
3117 #[test]
3120 fn parse_success_output_ndjson_envelope_fallback_skipped_when_is_error_true() {
3121 let ndjson = r#"{"type":"result","subtype":"error","is_error":true,"result":"Something went wrong with no status"}"#;
3122
3123 let runner = agents::get_runner("claude");
3124 let err = parse_success_output("149999", "claude", &*runner, ndjson)
3125 .expect_err("is_error=true should return AgentFailed");
3126
3127 assert!(matches!(err, agents::AgentError::AgentFailed { .. }));
3128 }
3129
3130 fn tool_pair(id: &str, name: &str, cmd: &str, is_error: bool) -> String {
3131 format!(
3132 "{}\n{}\n",
3133 serde_json::json!({"type":"assistant","message":{"content":[{"type":"tool_use","id":id,"name":name,"input":{"command":cmd}}]}}),
3134 serde_json::json!({"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":id,"is_error":is_error,"content":"x"}]}}),
3135 )
3136 }
3137
3138 #[test]
3139 fn salvage_wrapup_accepts_commit_then_cleanup() {
3140 let raw = tool_pair("1", "run_terminal_cmd", "git commit -m x", false)
3141 + &tool_pair("2", "run_terminal_cmd", "git status", false);
3142 assert!(salvage_wrapup(&raw).is_some());
3143 }
3144
3145 #[test]
3146 fn salvage_wrapup_rejects_edit_after_commit_or_unanswered_call() {
3147 let edit = tool_pair("1", "run_terminal_cmd", "git commit -m x", false)
3148 + &tool_pair("2", "search_replace", "", false);
3149 assert!(salvage_wrapup(&edit).is_none());
3150 let pending = tool_pair("1", "run_terminal_cmd", "git commit -m x", false)
3151 + &serde_json::json!({"type":"assistant","message":{"content":[{"type":"tool_use","id":"2","name":"run_terminal_cmd","input":{"command":"cargo test"}}]}}).to_string();
3152 assert!(salvage_wrapup(&pending).is_none());
3153 assert!(salvage_wrapup(&tool_pair("1", "run_terminal_cmd", "ls", false)).is_none());
3154 }
3155
3156 #[test]
3165 fn parse_session_output_exit1_with_terminal_reason_completed_returns_invalid_response() {
3166 let ndjson = r#"{"type":"result","subtype":"success","is_error":false,"terminal_reason":"completed","result":"","usage":{"input_tokens":1000,"output_tokens":100}}"#;
3172 let session = session::SessionOutput {
3173 exit_code: 1,
3174 raw_stdout: ndjson.to_string(),
3175 raw_stderr: String::new(),
3176 elapsed_secs: Some(30),
3177 };
3178
3179 let runner = agents::get_runner("claude");
3180 let result = parse_session_output("149298", "claude", &*runner, &session);
3181
3182 let err = result.expect_err("should be an error, not Ok");
3183 assert!(
3184 matches!(err, agents::AgentError::InvalidResponse { .. }),
3185 "should be InvalidResponse, got {err:?}"
3186 );
3187 }
3188
3189 #[test]
3195 fn parse_session_output_exit1_with_cost_telemetry_returns_invalid_response() {
3196 let telemetry = r#"{"type":"result","subtype":"success","is_error":false,"result":"","costUSD":0.042,"usage":{"input_tokens":5000,"output_tokens":800}}"#;
3198 let session = session::SessionOutput {
3199 exit_code: 1,
3200 raw_stdout: telemetry.to_string(),
3201 raw_stderr: String::new(),
3202 elapsed_secs: Some(120),
3203 };
3204
3205 let runner = agents::get_runner("glm");
3206 let result = parse_session_output("149347", "glm", &*runner, &session);
3207
3208 let err = result.expect_err("should be an error, not Ok");
3209 assert!(
3210 matches!(err, agents::AgentError::InvalidResponse { .. }),
3211 "glm cost-telemetry exit-1 should return InvalidResponse, got {err:?}"
3212 );
3213 }
3214
3215 #[test]
3218 fn parse_session_output_exit1_cost_telemetry_with_is_error_true_propagates_failure() {
3219 let telemetry = r#"{"type":"result","subtype":"error","is_error":true,"result":"tool failed","costUSD":0.01,"usage":{"input_tokens":100,"output_tokens":10}}"#;
3220 let session = session::SessionOutput {
3221 exit_code: 1,
3222 raw_stdout: telemetry.to_string(),
3223 raw_stderr: String::new(),
3224 elapsed_secs: Some(15),
3225 };
3226
3227 let runner = agents::get_runner("glm");
3228 let result = parse_session_output("149348", "glm", &*runner, &session);
3229
3230 let err = result.expect_err("should be an error");
3231 assert!(
3232 matches!(err, agents::AgentError::AgentFailed { .. }),
3233 "is_error=true with costUSD must still return AgentFailed, got {err:?}"
3234 );
3235 }
3236
3237 #[test]
3243 fn parse_session_output_exit1_preserves_error_when_is_error_true() {
3244 let ndjson = r#"{"type":"result","subtype":"error","is_error":true,"terminal_reason":"completed","result":"failed"}"#;
3245 let session = session::SessionOutput {
3246 exit_code: 1,
3247 raw_stdout: ndjson.to_string(),
3248 raw_stderr: String::new(),
3249 elapsed_secs: Some(30),
3250 };
3251
3252 let runner = agents::get_runner("kimi");
3253 let result = parse_session_output("149299", "kimi", &*runner, &session);
3254
3255 let err = result.expect_err("should be an error");
3256 assert!(
3257 matches!(err, agents::AgentError::AgentFailed { .. }),
3258 "is_error=true should return AgentFailed, got {err:?}"
3259 );
3260 }
3261
3262 #[test]
3268 fn parse_session_output_exit1_with_rate_limit_and_terminal_reason_completed() {
3269 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}}"#;
3270 let session = session::SessionOutput {
3271 exit_code: 1,
3272 raw_stdout: ndjson.to_string(),
3273 raw_stderr: String::new(),
3274 elapsed_secs: Some(120),
3275 };
3276
3277 let runner = agents::get_runner("claude");
3278 let result = parse_session_output("152895", "claude", &*runner, &session);
3279
3280 let err = result.expect_err("rate-limit should be an error");
3281 assert!(
3282 matches!(err, agents::AgentError::RateLimit { .. }),
3283 "session limit should return RateLimit, not downgraded, got {err:?}"
3284 );
3285 }
3286}