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