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