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