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