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