1use super::{EngineConfig, ProjectEngine};
17use crate::backends::github::GitHubBackend;
18use crate::backends::{ExternalBackend, ExternalId, ExternalTask, Status};
19use crate::channels::capture::CaptureService;
20use crate::channels::transport::Transport;
21use crate::cmd_cache;
22use crate::config;
23use crate::engine;
24use crate::engine::cooldown::{
25 SILENCE_AGENT_COOLDOWN_SECS, SILENCE_EXTENDED_COOLDOWN_SECS, github_circuit_remaining_secs,
26 is_agent_in_cooldown, is_github_circuit_open, is_model_in_cooldown,
27 record_agent_failure_with_message, record_silence_detection, set_agent_cooldown,
28 set_model_cooldown,
29};
30use crate::engine::dependencies;
31use crate::engine::dispatch_guard::DispatchGuard;
32use crate::engine::jobs;
33use crate::engine::priority;
34use crate::engine::priority::Priority;
35use crate::engine::router::config as router_config;
36use crate::engine::router::{
37 AllAgentsCooledError, RouteResultError, Router, RouterConfig, get_route_result,
38};
39use crate::engine::runner::response;
40use crate::engine::runner::{TaskRunner, WeightSignal};
41use crate::engine::sync;
42use crate::engine::tasks;
43use crate::engine::tasks::{TaskManager, is_internal_id};
44use crate::github::http;
45use crate::repo_context::REPO_CONTEXT;
46use crate::store;
47use crate::store::StoreRoute;
48use crate::store::Task;
49use crate::store::TaskStatus;
50use crate::store::TaskStatus as DbStatus;
51use crate::store::TaskStore;
52use crate::store::WaitingOnDependency;
53use crate::store::{store_set_by_id, store_touch_updated_at};
54use crate::tmux::TmuxManager;
55use dashmap::mapref::entry::Entry;
56use dashmap::{DashMap, DashSet};
57use futures::future;
58use std::collections::HashMap;
59use std::collections::HashSet;
60use std::path::Path;
61use std::sync::Arc;
62use std::time::Duration;
63use std::time::{Instant, SystemTime};
64use tokio::sync::RwLock;
65use tokio::sync::{Semaphore, mpsc};
66use tokio::time;
67
68pub(crate) struct RoutingTickQuota {
74 remaining: usize,
75}
76
77impl RoutingTickQuota {
78 pub(crate) fn new(max: usize) -> Self {
79 Self { remaining: max }
80 }
81
82 fn is_exhausted(&self) -> bool {
83 self.remaining == 0
84 }
85
86 fn remaining(&self) -> usize {
87 self.remaining
88 }
89
90 fn consume_one(&mut self) -> bool {
91 if self.remaining == 0 {
92 return false;
93 }
94 self.remaining -= 1;
95 true
96 }
97}
98
99pub(crate) async fn startup_cleanup(tmux: &TmuxManager) {
102 let start_time = SystemTime::now()
103 .duration_since(SystemTime::UNIX_EPOCH)
104 .unwrap_or_default()
105 .as_secs();
106 tracing::info!(
107 start_time,
108 "running startup cleanup for stale tmux sessions"
109 );
110
111 match tmux.kill_stale_sessions(start_time).await {
112 Ok(killed) => {
113 if killed > 0 {
114 tracing::info!(killed, "startup cleanup killed stale sessions");
115 } else {
116 tracing::debug!("no stale tmux sessions found on startup");
117 }
118 }
119 Err(e) => {
120 tracing::warn!(error = %e, "failed to run startup cleanup");
121 }
122 }
123}
124
125async fn recover_routed_blocked_dispatch(
126 backend: &Arc<dyn ExternalBackend>,
127 store: &Arc<TaskStore>,
128 task_manager: &Arc<TaskManager>,
129 repo: &str,
130 task: &ExternalTask,
131 timing: &StuckTaskTiming,
132) {
133 tracing::warn!(
134 task_id = task.id.0,
135 age_mins = timing.age.num_minutes(),
136 threshold_mins = timing.threshold / 60,
137 "recovering routed task: dispatch blocked by stale tmux session → new"
138 );
139
140 let resolved_store_id = match store.resolve_task_id(repo, &task.id.0).await {
141 Ok(Some(id)) => Some(id),
142 Ok(None) => {
143 tracing::warn!(
144 task_id = %task.id.0,
145 repo,
146 "resolve_task_id returned None during routed-task recovery"
147 );
148 None
149 }
150 Err(e) => {
151 tracing::error!(
152 task_id = %task.id.0,
153 repo,
154 error = %e,
155 "resolve_task_id failed during routed-task recovery"
156 );
157 None
158 }
159 };
160
161 if let Some(store_id) = resolved_store_id {
162 if let Err(e) = store_set_by_id(
163 Some(store),
164 store_id,
165 &[
166 ("agent", serde_json::Value::Null),
167 ("model", serde_json::Value::Null),
168 ("route_attempts", serde_json::json!(0)),
169 ],
170 )
171 .await
172 {
173 tracing::warn!(
174 task_id = task.id.0,
175 ?e,
176 "failed to clear routing fields for routed stuck task — will retry next tick"
177 );
178 return;
179 }
180 finalize_open_runs_for_recovery(
181 store,
182 store_id,
183 "stuck-task recovery: stale session blocking dispatch killed",
184 )
185 .await;
186 }
187
188 if let Err(e) = task_manager.update_task_status(&task.id, Status::New).await {
189 tracing::warn!(
190 task_id = task.id.0,
191 ?e,
192 "failed to reset routed stuck task status"
193 );
194 return;
195 }
196
197 store::store_log_activity(
198 &Some(Arc::clone(store)),
199 repo,
200 &task.id.0,
201 "rerouted",
202 Some("routed"),
203 Some("new"),
204 None::<&str>,
205 None::<&str>,
206 Some(&serde_json::json!({
207 "failure_reason": "routed_blocked_by_existing_session",
208 "age_minutes": timing.age.num_minutes(),
209 "had_session": true,
210 })),
211 )
212 .await;
213
214 if !is_internal_id(&task.id.0) {
215 let backend_clone = Arc::clone(backend);
216 let task_id_clone = task.id.clone();
217 let body = format!(
218 "[{}] recovered: stuck routed task — existing tmux session blocked dispatch for {}m; cleared route for retry{}",
219 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
220 timing.age.num_minutes(),
221 engine::orch_footer()
222 );
223 tokio::spawn(async move {
224 if let Err(e) = backend_clone.post_comment(&task_id_clone, &body).await {
225 tracing::warn!(
226 task_id = task_id_clone.0,
227 ?e,
228 "failed to post routed-task recovery comment"
229 );
230 }
231 });
232 }
233}
234
235pub(crate) async fn tick_check_session_completions(
237 tmux: &Arc<TmuxManager>,
238 repo: &str,
239 capture: &Arc<CaptureService>,
240 store: &Arc<TaskStore>,
241) -> anyhow::Result<()> {
242 let _span = tracing::info_span!("engine.tick.phase1.sessions").entered();
243 let session_snapshot = tmux.snapshot().await;
244 let repo_name = repo
250 .rsplit('/')
251 .next()
252 .unwrap_or(repo)
253 .trim_end_matches(".git");
254 for (session, active) in session_snapshot {
255 if session.project != repo_name {
257 continue;
258 }
259
260 if !active {
261 tracing::info!(
262 session = %session.name,
263 task_id = %session.task_id,
264 "session completed, collecting results"
265 );
266 let store_task_id = {
277 let base = session
278 .task_id
279 .strip_suffix("-review")
280 .unwrap_or(&session.task_id);
281 if let Some(n) = base.strip_prefix("internal-") {
282 format!("internal:{n}")
283 } else {
284 base.to_string()
285 }
286 };
287 store_touch_updated_at(&Some(Arc::clone(store)), repo, &store_task_id).await;
288 capture.unregister_session(repo, &session.task_id).await;
291 if let Err(e) = tmux.kill_session(&session.name).await {
293 tracing::debug!(
294 session = %session.name,
295 ?e,
296 "kill_session failed (session may already be gone)"
297 );
298 }
299 }
300 }
301 Ok(())
302}
303
304#[derive(Debug)]
305struct StuckTaskTiming {
306 has_session: bool,
307 age: chrono::Duration,
308 threshold: u64,
309}
310
311fn stuck_task_timing_from_map(
312 tmux: &Arc<TmuxManager>,
313 repo: &str,
314 session_task_id: &str,
315 updated_at: &str,
316 config: &EngineConfig,
317 parse_error_message: &'static str,
318 session_map: &HashMap<String, bool>,
319) -> Option<StuckTaskTiming> {
320 let has_session = session_alive(tmux, repo, session_task_id, session_map);
321 let threshold = if has_session {
322 config.stuck_timeout
323 } else {
324 config.no_session_stuck_timeout
325 };
326
327 let updated = match chrono::DateTime::parse_from_rfc3339(updated_at) {
328 Ok(dt) => dt.with_timezone(&chrono::Utc),
329 Err(e) => {
330 tracing::warn!(
331 task_id = session_task_id,
332 updated_at,
333 ?e,
334 error_message = parse_error_message,
335 "cannot parse updated_at"
336 );
337 return None;
338 }
339 };
340
341 let raw_age = chrono::Utc::now() - updated;
342 let suspended = super::suspend::suspended_duration_since(updated);
346 let age = (raw_age - suspended).max(chrono::Duration::zero());
347 if age.num_seconds() <= i64::try_from(threshold).unwrap_or(i64::MAX) {
348 return None;
349 }
350
351 Some(StuckTaskTiming {
352 has_session,
353 age,
354 threshold,
355 })
356}
357
358fn session_alive(
359 tmux: &TmuxManager,
360 repo: &str,
361 session_task_id: &str,
362 session_map: &HashMap<String, bool>,
363) -> bool {
364 session_map
365 .get(&tmux.session_name(repo, session_task_id))
366 .copied()
367 .unwrap_or(false)
368}
369
370fn run_tier(router: &RouterConfig, task: &Task) -> String {
373 match (task.agent.as_deref(), task.model.as_deref()) {
374 (Some(a), Some(m)) if !m.is_empty() => router.tier_of_model(a, m, &task.complexity),
375 _ => task.complexity.clone(),
376 }
377}
378
379async fn in_progress_stuck_clock(
384 store: &TaskStore,
385 repo: &str,
386 task_id: &str,
387 updated_at: &str,
388 has_session: bool,
389 config: &EngineConfig,
390) -> (String, EngineConfig) {
391 if !has_session {
392 return (updated_at.to_string(), config.clone());
393 }
394
395 let task = match store.resolve_task_id(repo, task_id).await {
396 Ok(Some(store_id)) => store.get(store_id).await.ok(),
397 _ => None,
398 };
399 let Some(task) = task else {
400 return (updated_at.to_string(), config.clone());
401 };
402
403 let entered_at = match store
404 .status_entered_at(task.id, TaskStatus::InProgress)
405 .await
406 {
407 Ok(Some(ts)) => ts,
408 Ok(None) => updated_at.to_string(),
409 Err(e) => {
410 tracing::warn!(
411 task_id,
412 error = %e,
413 "failed to read in_progress entry, falling back to updated_at"
414 );
415 updated_at.to_string()
416 }
417 };
418 let tier = run_tier(&RouterConfig::from_config(), &task);
419 let task_config = EngineConfig {
420 stuck_timeout: config.live_session_stuck_limit(Some(&tier)),
421 ..config.clone()
422 };
423 (entered_at, task_config)
424}
425
426async fn in_review_stuck_entry(store: &TaskStore, store_id: i64, fallback: &str) -> (String, bool) {
429 match store.in_review_entry(store_id).await {
430 Ok(Some(entry)) => entry,
431 Ok(None) => (fallback.to_string(), false),
432 Err(e) => {
433 tracing::warn!(
434 store_id,
435 error = %e,
436 "failed to read in_review entry, falling back to updated_at"
437 );
438 (fallback.to_string(), false)
439 }
440 }
441}
442
443async fn finalize_open_runs_for_recovery(store: &TaskStore, store_id: i64, reason: &str) {
446 if let Err(e) = store
447 .finalize_incomplete_runs(store_id, "aborted", reason)
448 .await
449 {
450 tracing::warn!(
451 store_id,
452 error = %e,
453 reason,
454 "failed to finalize incomplete runs during recovery"
455 );
456 }
457}
458
459pub(crate) async fn tick_detect_silent_agents(
468 tmux: &Arc<TmuxManager>,
469 repo: &str,
470 capture: &Arc<CaptureService>,
471 backend: &Arc<dyn ExternalBackend>,
472 task_manager: &Arc<TaskManager>,
473 config: &EngineConfig,
474 store: &Arc<TaskStore>,
475) -> anyhow::Result<()> {
476 let _span = tracing::info_span!("engine.tick.phase1b.silence").entered();
477 let grace = Duration::from_secs(config.silence_grace_period);
478 let silent_sessions = capture.get_silent_sessions_for_repo(repo, grace).await;
479
480 for (task_id, session_name, silence_age_secs) in silent_sessions {
481 let use_backend = should_use_backend(&task_id);
482 let store_task = match store.resolve_task_id(repo, &task_id).await {
484 Ok(Some(store_id)) => match store.get(store_id).await {
485 Ok(task) => Some(task),
486 Err(e) => {
487 tracing::warn!(
488 task_id,
489 repo,
490 store_id,
491 operation = "store.get",
492 error = %e,
493 "silence detection: db error fetching task — agent/model cooldown will be skipped"
494 );
495 None
496 }
497 },
498 Ok(None) => {
499 tracing::debug!(
500 task_id,
501 repo,
502 operation = "resolve_task_id",
503 "silence detection: task not found in store (may have been cleaned up)"
504 );
505 None
506 }
507 Err(e) => {
508 tracing::warn!(
509 task_id,
510 repo,
511 operation = "resolve_task_id",
512 error = %e,
513 "silence detection: db error resolving task id — agent/model cooldown will be skipped"
514 );
515 None
516 }
517 };
518 let agent_name = store_task
519 .as_ref()
520 .and_then(|t| t.agent.clone())
521 .unwrap_or_default();
522 let model_name = store_task
523 .as_ref()
524 .and_then(|t| t.model.clone())
525 .unwrap_or_default();
526
527 tracing::warn!(
528 task_id,
529 agent = %agent_name,
530 model = %model_name,
531 silence_age_secs,
532 grace_secs = config.silence_grace_period,
533 cooldown_secs = config.silence_cooldown,
534 "agent silent since session start — killing session, cooling down model + agent, failing over"
535 );
536
537 if let Err(e) = tmux.kill_session(&session_name).await {
539 tracing::debug!(
540 task_id,
541 ?e,
542 "kill_session failed for silent agent (may already be gone)"
543 );
544 }
545
546 capture.unregister_session(repo, &task_id).await;
548
549 if let Some(store_id) = store_task.as_ref().map(|t| t.id) {
551 finalize_open_runs_for_recovery(store, store_id, "silence detection: session killed")
552 .await;
553 }
554
555 let mut extended_note = String::new();
556
557 if !agent_name.is_empty() && !model_name.is_empty() {
559 set_model_cooldown(
560 &agent_name,
561 &model_name,
562 config.silence_cooldown,
563 "silence_detected",
564 )
565 .await;
566 if let Some(result) = record_silence_detection(&agent_name, &model_name).await
567 && result.extended_cooldown_applied
568 {
569 extended_note = format!(
570 " ({} silences in 24h -> extended cooldown {}s)",
571 result.count, SILENCE_EXTENDED_COOLDOWN_SECS
572 );
573 }
574 }
575
576 if !agent_name.is_empty() {
580 set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
581 }
582
583 let task_eid = ExternalId(task_id.clone());
588
589 let available: Vec<String> = engine::configured_agents()
591 .into_iter()
592 .filter(|a| cmd_cache::command_exists(a))
593 .collect();
594 let chain = response::get_reroute_chain(&task_id, &Some(Arc::clone(store)), repo).await;
595 let chain = response::update_reroute_chain(
596 &task_id,
597 &agent_name,
598 &chain,
599 &Some(Arc::clone(store)),
600 repo,
601 )
602 .await;
603
604 let (next_status, next_agent) = if let Some(fallback) =
605 response::pick_fallback_agent(&agent_name, &chain, &available)
606 {
607 tracing::info!(
608 task_id,
609 from = %agent_name,
610 to = %fallback,
611 "silence detection: failover to different agent, setting routed"
612 );
613 (Status::Routed, Some(fallback))
614 } else {
615 tracing::warn!(
616 task_id,
617 "silence detection: no fallback agents available, resetting to new for re-routing"
618 );
619 (Status::New, None)
620 };
621
622 if use_backend && let Some(ref st) = store_task {
623 let stale_labels: Vec<String> = st
625 .labels
626 .iter()
627 .filter(|l| {
628 l.starts_with("agent:")
629 || l.starts_with("complexity:")
630 || l.starts_with("model:")
631 })
632 .cloned()
633 .collect();
634 if !stale_labels.is_empty() {
635 let backend_clone = Arc::clone(backend);
636 let task_eid_clone = task_eid.clone();
637 tokio::spawn(async move {
638 for label in &stale_labels {
639 if let Err(e) = backend_clone.remove_label(&task_eid_clone, label).await {
640 tracing::warn!(task_id = task_eid_clone.0, label, error = %e, "failed to remove label during silence detection re-route");
641 }
642 }
643 });
644 }
645 }
646
647 if let Some(store_id) = store_task.as_ref().map(|t| t.id) {
648 if let Some(ref fallback) = next_agent {
649 let _ = store_set_by_id(
653 Some(store),
654 store_id,
655 &[
656 ("agent", serde_json::json!(fallback)),
657 ("model", serde_json::json!("")),
658 (
659 "last_error",
660 serde_json::json!(format!(
661 "silence detected after {}s, failing over to {}",
662 config.silence_grace_period, fallback
663 )),
664 ),
665 ],
666 )
667 .await;
668 } else {
669 let _ = store_set_by_id(
670 Some(store),
671 store_id,
672 &[
673 ("agent", serde_json::Value::Null),
674 ("model", serde_json::Value::Null),
675 (
676 "last_error",
677 serde_json::json!(format!(
678 "silence detected after {}s, no fallback agents available",
679 config.silence_grace_period
680 )),
681 ),
682 ],
683 )
684 .await;
685 }
686 }
687
688 if let Err(e) = task_manager
689 .update_task_status(&task_eid, next_status)
690 .await
691 {
692 tracing::warn!(task_id, ?e, "failed to update silent task status");
693 continue;
694 }
695
696 let details = serde_json::json!({
698 "failure_reason": "agent_silence",
699 "silence_duration_secs": config.silence_grace_period,
700 "cooldown_applied": !agent_name.is_empty() && !model_name.is_empty(),
701 "fallback_available": next_agent.is_some(),
702 });
703 store::store_log_activity(
704 &Some(Arc::clone(store)),
705 repo,
706 &task_id,
707 "rerouted",
708 Some("in_progress"),
709 Some(next_status.as_str()),
710 next_agent.as_deref(),
711 None,
712 Some(&details),
713 )
714 .await;
715
716 let action = if let Some(ref fallback) = next_agent {
718 format!("failing over to {fallback}")
719 } else {
720 "marking needs_review (no fallback agents)".to_string()
721 };
722 let comment = format!(
723 "[{}] agent silent for {}s since session start — killed session, cooled down model `{}:{}` for {}s{}, agent `{}` for {}s, {}{}",
724 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
725 config.silence_grace_period,
726 agent_name,
727 model_name,
728 config.silence_cooldown,
729 extended_note,
730 agent_name,
731 SILENCE_AGENT_COOLDOWN_SECS,
732 action,
733 engine::orch_footer(),
734 );
735 if use_backend {
736 let backend_clone = Arc::clone(backend);
738 let task_eid_clone = task_eid.clone();
739 tokio::spawn(async move {
740 if let Err(e) = backend_clone.post_comment(&task_eid_clone, &comment).await {
741 tracing::warn!(
742 task_id = task_eid_clone.0,
743 ?e,
744 "failed to post silence detection comment"
745 );
746 }
747 });
748 }
749 }
750
751 Ok(())
752}
753
754fn should_use_backend(task_id: &str) -> bool {
755 !is_internal_id(task_id)
756}
757
758#[expect(
763 clippy::too_many_arguments,
764 reason = "each argument is engine state the tick passes down"
765)]
766pub(crate) async fn tick_recover_stuck_tasks(
767 backend: &Arc<dyn ExternalBackend>,
768 tmux: &Arc<TmuxManager>,
769 repo: &str,
770 task_manager: &Arc<TaskManager>,
771 config: &EngineConfig,
772 store: &Arc<TaskStore>,
773 session_map: &HashMap<String, bool>,
774 dispatching: &Arc<DashMap<String, String>>,
775) -> anyhow::Result<()> {
776 let _span = tracing::info_span!("engine.tick.phase2.stuck_tasks").entered();
777
778 let in_progress = match task_manager
779 .list_external_by_status(Status::InProgress)
780 .await
781 {
782 Ok(tasks) => tasks,
783 Err(e) => {
784 tracing::warn!(?e, "failed to list in_progress tasks for stuck recovery");
785 vec![]
786 }
787 };
788 for task in &in_progress {
789 let (since, task_config) = in_progress_stuck_clock(
790 store,
791 repo,
792 &task.id.0,
793 &task.updated_at,
794 session_alive(tmux, repo, &task.id.0, session_map),
795 config,
796 )
797 .await;
798 let Some(timing) = stuck_task_timing_from_map(
799 tmux,
800 repo,
801 &task.id.0,
802 &since,
803 &task_config,
804 "cannot parse updated_at, skipping stuck-task check",
805 session_map,
806 ) else {
807 continue;
808 };
809
810 if !timing.has_session {
817 let dispatch_key = format!("{repo}/{}", task.id.0);
818 if dispatching.contains_key(&dispatch_key) {
819 tracing::info!(
827 task_id = task.id.0,
828 dispatch_key,
829 age_mins = timing.age.num_minutes(),
830 "skipping stuck-task reclaim: runner still actively processing this task"
831 );
832 continue;
833 }
834 }
835
836 if timing.has_session {
837 tracing::warn!(
838 task_id = task.id.0,
839 age_mins = timing.age.num_minutes(),
840 threshold_mins = timing.threshold / 60,
841 "recovering stuck task: timed out with active session → new"
842 );
843 let session_name = tmux.session_name(repo, &task.id.0);
846 if let Err(e) = tmux.kill_session(&session_name).await {
847 tracing::warn!(
848 task_id = task.id.0,
849 session = %session_name,
850 error = %e,
851 "failed to kill stuck tmux session, will retry next tick"
852 );
853 continue;
854 }
855 } else {
856 tracing::warn!(
857 task_id = task.id.0,
858 dispatch_key = format!("{repo}/{}", task.id.0),
859 age_mins = timing.age.num_minutes(),
860 threshold_mins = timing.threshold / 60,
861 "recovering stuck task: no session found — reclaiming early → new"
862 );
863 }
864 let mut cached_store_id: Option<i64> = None;
869 if timing.has_session {
870 let store_task = match store.resolve_task_id(repo, &task.id.0).await {
871 Ok(Some(store_id)) => {
872 cached_store_id = Some(store_id);
873 match store.get(store_id).await {
874 Ok(t) => Some(t),
875 Err(e) => {
876 tracing::warn!(task_id = task.id.0, error = %e, "failed to fetch task from store for stuck-task cooldown");
877 None
878 }
879 }
880 }
881 Ok(None) => None,
882 Err(e) => {
883 tracing::warn!(task_id = task.id.0, error = %e, "failed to resolve task id for stuck-task cooldown");
884 None
885 }
886 };
887 let agent_name = store_task
888 .as_ref()
889 .and_then(|t| t.agent.clone())
890 .unwrap_or_default();
891 let model_name = store_task
892 .as_ref()
893 .and_then(|t| t.model.clone())
894 .unwrap_or_default();
895
896 if !agent_name.is_empty() && !model_name.is_empty() {
897 set_model_cooldown(
898 &agent_name,
899 &model_name,
900 config.silence_cooldown,
901 "silence_detected",
902 )
903 .await;
904 record_agent_failure_with_message(
905 &agent_name,
906 &format!(
907 "stuck with active session after {}m",
908 timing.age.num_minutes()
909 ),
910 )
911 .await;
912 }
913 if !agent_name.is_empty() {
914 set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
915 }
916 tracing::warn!(
917 task_id = task.id.0,
918 agent = %agent_name,
919 model = %model_name,
920 cooldown_secs = config.silence_cooldown,
921 "stuck-task cooldown applied — router will avoid this agent/model on next attempt"
922 );
923 }
924
925 {
928 let stale_labels: Vec<String> = task
929 .labels
930 .iter()
931 .filter(|l| l.starts_with("agent:") || l.starts_with("model:"))
932 .cloned()
933 .collect();
934 if !stale_labels.is_empty() {
935 let backend_clone = Arc::clone(backend);
936 let task_id_clone = task.id.clone();
937 tokio::spawn(async move {
938 for label in &stale_labels {
939 if let Err(e) = backend_clone.remove_label(&task_id_clone, label).await {
940 tracing::warn!(task_id = task_id_clone.0, label, error = %e, "failed to remove stale routing label during stuck-task recovery");
941 }
942 }
943 });
944 }
945 }
946 let resolved_store_id = match cached_store_id {
947 Some(id) => Some(id),
948 None => match store.resolve_task_id(repo, &task.id.0).await {
949 Ok(Some(id)) => Some(id),
950 Ok(None) => {
951 tracing::warn!(
952 task_id = %task.id.0,
953 repo,
954 "resolve_task_id returned None during stuck-task recovery"
955 );
956 None
957 }
958 Err(e) => {
959 tracing::error!(
960 task_id = %task.id.0,
961 repo,
962 error = %e,
963 "resolve_task_id failed during stuck-task recovery"
964 );
965 None
966 }
967 },
968 };
969 if let Some(store_id) = resolved_store_id {
970 if let Err(e) = store_set_by_id(
971 Some(store),
972 store_id,
973 &[
974 ("agent", serde_json::Value::Null),
975 ("model", serde_json::Value::Null),
976 ("route_attempts", serde_json::json!(0)),
977 ],
978 )
979 .await
980 {
981 tracing::warn!(
982 task_id = task.id.0,
983 ?e,
984 "failed to clear routing fields for stuck task — will retry next tick"
985 );
986 continue;
987 }
988 finalize_open_runs_for_recovery(
989 store,
990 store_id,
991 if timing.has_session {
992 "stuck-task recovery: active session killed"
993 } else {
994 "stuck-task recovery: no session found"
995 },
996 )
997 .await;
998 }
999 if let Err(e) = task_manager.update_task_status(&task.id, Status::New).await {
1000 tracing::warn!(task_id = task.id.0, ?e, "failed to reset stuck task status");
1001 continue;
1002 }
1003
1004 let details = serde_json::json!({
1006 "failure_reason": if timing.has_session { "stuck_with_session" } else { "stuck_no_session" },
1007 "age_minutes": timing.age.num_minutes(),
1008 "had_session": timing.has_session,
1009 });
1010 store::store_log_activity(
1011 &Some(Arc::clone(store)),
1012 repo,
1013 &task.id.0,
1014 "rerouted",
1015 Some("in_progress"),
1016 Some("new"),
1017 None::<&str>,
1018 None::<&str>,
1019 Some(&details),
1020 )
1021 .await;
1022
1023 let reason = if timing.has_session {
1024 format!(
1025 "timed out after {}m with active session (cleared agent for re-routing)",
1026 timing.age.num_minutes()
1027 )
1028 } else {
1029 format!(
1030 "no session found — reclaiming early after {}m (cleared agent for re-routing)",
1031 timing.age.num_minutes()
1032 )
1033 };
1034 {
1036 let backend_clone = Arc::clone(backend);
1037 let task_id_clone = task.id.clone();
1038 let body = format!(
1039 "[{}] recovered: stuck in_progress — {}{}",
1040 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
1041 reason,
1042 engine::orch_footer()
1043 );
1044 tokio::spawn(async move {
1045 if let Err(e) = backend_clone.post_comment(&task_id_clone, &body).await {
1046 tracing::warn!(
1047 task_id = task_id_clone.0,
1048 ?e,
1049 "failed to post stuck-task recovery comment"
1050 );
1051 }
1052 });
1053 }
1054 }
1055
1056 let mut routed = match task_manager.list_external_by_status(Status::Routed).await {
1057 Ok(tasks) => tasks,
1058 Err(e) => {
1059 tracing::warn!(?e, "failed to list routed tasks for stuck recovery");
1060 vec![]
1061 }
1062 };
1063 match task_manager
1064 .list_internal_by_status(TaskStatus::Routed)
1065 .await
1066 {
1067 Ok(tasks) => routed.extend(tasks),
1068 Err(e) => tracing::warn!(
1069 ?e,
1070 "failed to list internal routed tasks for stuck recovery"
1071 ),
1072 }
1073 for task in &routed {
1074 let Some(timing) = stuck_task_timing_from_map(
1075 tmux,
1076 repo,
1077 &task.id.0,
1078 &task.updated_at,
1079 config,
1080 "cannot parse updated_at, skipping routed stuck-task check",
1081 session_map,
1082 ) else {
1083 continue;
1084 };
1085
1086 if !timing.has_session {
1087 continue;
1088 }
1089
1090 let session_name = tmux.session_name(repo, &task.id.0);
1091 if let Err(e) = tmux.kill_session(&session_name).await {
1092 tracing::warn!(
1093 task_id = task.id.0,
1094 session = %session_name,
1095 error = %e,
1096 "failed to kill stale tmux session blocking routed task"
1097 );
1098 continue;
1099 }
1100
1101 recover_routed_blocked_dispatch(backend, store, task_manager, repo, task, &timing).await;
1102 }
1103
1104 let internal_in_progress = match task_manager
1107 .list_internal_by_status(DbStatus::InProgress)
1108 .await
1109 {
1110 Ok(tasks) => tasks,
1111 Err(e) => {
1112 tracing::warn!(
1113 ?e,
1114 "failed to list internal in_progress tasks for stuck recovery"
1115 );
1116 vec![]
1117 }
1118 };
1119 for task in &internal_in_progress {
1120 let task_id = task.id.0.clone();
1121 let (since, task_config) = in_progress_stuck_clock(
1122 store,
1123 repo,
1124 &task_id,
1125 &task.updated_at,
1126 session_alive(tmux, repo, &task_id, session_map),
1127 config,
1128 )
1129 .await;
1130 let Some(timing) = stuck_task_timing_from_map(
1131 tmux,
1132 repo,
1133 &task_id,
1134 &since,
1135 &task_config,
1136 "cannot parse updated_at, skipping stuck internal-task check",
1137 session_map,
1138 ) else {
1139 continue;
1140 };
1141
1142 if !timing.has_session {
1148 let dispatch_key = format!("{repo}/{task_id}");
1149 if dispatching.contains_key(&dispatch_key) {
1150 tracing::info!(
1160 task_id,
1161 dispatch_key,
1162 age_mins = timing.age.num_minutes(),
1163 "skipping stuck-task reclaim: runner still actively processing this task"
1164 );
1165 continue;
1166 }
1167 }
1168
1169 if timing.has_session {
1170 tracing::warn!(
1171 task_id,
1172 age_mins = timing.age.num_minutes(),
1173 threshold_mins = timing.threshold / 60,
1174 "recovering stuck task: timed out with active session → new"
1175 );
1176 let session_name = tmux.session_name(repo, &task_id);
1177 if let Err(e) = tmux.kill_session(&session_name).await {
1178 tracing::warn!(
1179 task_id,
1180 session = %session_name,
1181 error = %e,
1182 "failed to kill stuck tmux session, will retry next tick"
1183 );
1184 continue;
1185 }
1186 } else {
1187 tracing::warn!(
1188 task_id,
1189 dispatch_key = format!("{repo}/{task_id}"),
1190 age_mins = timing.age.num_minutes(),
1191 threshold_mins = timing.threshold / 60,
1192 "recovering stuck task: no session found — reclaiming early → new"
1193 );
1194 }
1195 let mut cached_store_id: Option<i64> = None;
1199 if timing.has_session {
1200 let store_task = match store.resolve_task_id(repo, &task_id).await {
1201 Ok(Some(store_id)) => {
1202 cached_store_id = Some(store_id);
1203 match store.get(store_id).await {
1204 Ok(t) => Some(t),
1205 Err(e) => {
1206 tracing::warn!(task_id, error = %e, "failed to fetch task from store for stuck internal-task cooldown");
1207 None
1208 }
1209 }
1210 }
1211 Ok(None) => None,
1212 Err(e) => {
1213 tracing::warn!(task_id, error = %e, "failed to resolve task id for stuck internal-task cooldown");
1214 None
1215 }
1216 };
1217 let agent_name = store_task
1218 .as_ref()
1219 .and_then(|t| t.agent.clone())
1220 .unwrap_or_default();
1221 let model_name = store_task
1222 .as_ref()
1223 .and_then(|t| t.model.clone())
1224 .unwrap_or_default();
1225
1226 if !agent_name.is_empty() && !model_name.is_empty() {
1227 set_model_cooldown(
1228 &agent_name,
1229 &model_name,
1230 config.silence_cooldown,
1231 "silence_detected",
1232 )
1233 .await;
1234 record_agent_failure_with_message(
1235 &agent_name,
1236 &format!(
1237 "internal task stuck with active session after {}m",
1238 timing.age.num_minutes()
1239 ),
1240 )
1241 .await;
1242 }
1243 if !agent_name.is_empty() {
1244 set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
1245 }
1246 tracing::warn!(
1247 task_id,
1248 agent = %agent_name,
1249 model = %model_name,
1250 cooldown_secs = config.silence_cooldown,
1251 "internal stuck-task cooldown applied — router will avoid this agent/model on next attempt"
1252 );
1253 }
1254 let resolved_store_id = match cached_store_id {
1257 Some(id) => Some(id),
1258 None => match store.resolve_task_id(repo, &task_id).await {
1259 Ok(Some(id)) => Some(id),
1260 Ok(None) => {
1261 tracing::warn!(
1262 task_id = %task_id,
1263 repo,
1264 "resolve_task_id returned None during stuck internal-task recovery"
1265 );
1266 None
1267 }
1268 Err(e) => {
1269 tracing::error!(
1270 task_id = %task_id,
1271 repo,
1272 error = %e,
1273 "resolve_task_id failed during stuck internal-task recovery"
1274 );
1275 None
1276 }
1277 },
1278 };
1279 if let Some(store_id) = resolved_store_id {
1280 if let Err(e) = store_set_by_id(
1281 Some(store),
1282 store_id,
1283 &[
1284 ("agent", serde_json::Value::Null),
1285 ("model", serde_json::Value::Null),
1286 ("route_attempts", serde_json::json!(0)),
1287 ],
1288 )
1289 .await
1290 {
1291 tracing::warn!(
1292 task_id,
1293 ?e,
1294 "failed to clear routing fields for stuck internal task — will retry next tick"
1295 );
1296 continue;
1297 }
1298 finalize_open_runs_for_recovery(
1299 store,
1300 store_id,
1301 if timing.has_session {
1302 "stuck-task recovery: internal active session killed"
1303 } else {
1304 "stuck-task recovery: internal no session found"
1305 },
1306 )
1307 .await;
1308 }
1309 if let Err(e) = task_manager
1310 .update_task_status(&ExternalId(task_id.clone()), Status::New)
1311 .await
1312 {
1313 tracing::warn!(task_id, ?e, "failed to reset stuck internal task status");
1314 }
1315 }
1316
1317 let in_review = match store.list_by_status(repo, TaskStatus::InReview).await {
1324 Ok(v) => v,
1325 Err(e) => {
1326 tracing::warn!(?e, "failed to list in_review tasks for stuck recovery");
1327 vec![]
1328 }
1329 };
1330 let in_review_config = engine::EngineConfig {
1334 no_session_stuck_timeout: config.in_review_no_session_stuck_timeout,
1335 ..config.clone()
1336 };
1337 for task in &in_review {
1338 let Some(task_id) = task.external_id.clone() else {
1339 tracing::warn!(
1340 store_id = task.id,
1341 "in_review task has no external id, skipping stuck recovery"
1342 );
1343 continue;
1344 };
1345
1346 if dispatching.contains_key(&format!("{repo}/{task_id}")) {
1348 tracing::debug!(
1349 task_id,
1350 "task locked by dispatch flow, skipping stuck check"
1351 );
1352 continue;
1353 }
1354
1355 let (entered_at, decided) = in_review_stuck_entry(store, task.id, &task.updated_at).await;
1358 if decided {
1359 tracing::debug!(
1360 task_id,
1361 "in_review task already has a review decision, waiting on merge"
1362 );
1363 continue;
1364 }
1365
1366 let review_task_id = format!("{task_id}-review");
1367 let Some(timing) = stuck_task_timing_from_map(
1368 tmux,
1369 repo,
1370 &review_task_id,
1371 &entered_at,
1372 &in_review_config,
1373 "cannot parse in_review timestamp, skipping stuck in_review check",
1374 session_map,
1375 ) else {
1376 continue;
1377 };
1378
1379 tracing::warn!(
1380 task_id,
1381 age_mins = timing.age.num_minutes(),
1382 threshold_mins = timing.threshold / 60,
1383 has_session = timing.has_session,
1384 "recovering stuck in_review task — resetting to needs_review"
1385 );
1386
1387 match task_manager
1388 .update_task_status_if(
1389 &ExternalId(task_id.clone()),
1390 Status::NeedsReview,
1391 Status::InReview,
1392 )
1393 .await
1394 {
1395 Err(e) => {
1396 tracing::warn!(task_id, ?e, "failed to reset stuck in_review task status");
1397 continue;
1398 }
1399 Ok(false) => {
1400 tracing::debug!(
1401 task_id,
1402 "stuck in_review reset skipped — task already transitioned"
1403 );
1404 continue;
1405 }
1406 Ok(true) => {}
1407 }
1408
1409 if let Err(e) = store
1412 .set_fields(task.id, &[("review_agent_failures", serde_json::json!(0))])
1413 .await
1414 {
1415 tracing::warn!(task_id, ?e, "failed to reset review_agent_failures");
1416 }
1417
1418 finalize_open_runs_for_recovery(
1419 store,
1420 task.id,
1421 if timing.has_session {
1422 "stuck-task recovery: in_review session killed"
1423 } else {
1424 "stuck-task recovery: in_review no session found"
1425 },
1426 )
1427 .await;
1428
1429 let details = serde_json::json!({
1430 "failure_reason": "stuck_in_review",
1431 "age_minutes": timing.age.num_minutes(),
1432 "had_session": timing.has_session,
1433 });
1434 store::store_log_activity(
1435 &Some(Arc::clone(store)),
1436 repo,
1437 &task_id,
1438 "rerouted",
1439 Some("in_review"),
1440 Some("needs_review"),
1441 None::<&str>,
1442 None::<&str>,
1443 Some(&details),
1444 )
1445 .await;
1446
1447 if task.origin == "internal" {
1449 continue;
1450 }
1451
1452 if let Err(e) = backend
1453 .post_comment(
1454 &ExternalId(task_id.clone()),
1455 &format!(
1456 "[{}] recovered: stuck in_review — {} after {}m, resetting to needs_review{}",
1457 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
1458 if timing.has_session {
1459 "review session hung"
1460 } else {
1461 "no session found"
1462 },
1463 timing.age.num_minutes(),
1464 engine::orch_footer()
1465 ),
1466 )
1467 .await
1468 {
1469 tracing::warn!(
1470 task_id,
1471 ?e,
1472 "failed to post stuck in_review recovery comment"
1473 );
1474 }
1475 }
1476
1477 Ok(())
1478}
1479
1480pub(crate) async fn recover_untracked_repo_stuck_tasks(
1483 tmux: &Arc<TmuxManager>,
1484 config: &EngineConfig,
1485 store: &Arc<TaskStore>,
1486 session_map: &HashMap<String, bool>,
1487 dispatching: &Arc<DashMap<String, String>>,
1488 active_repos: &HashSet<String>,
1489) {
1490 let all_external_in_progress = match store
1491 .list_all_by_status_global(TaskStatus::InProgress)
1492 .await
1493 {
1494 Ok(tasks) => tasks,
1495 Err(e) => {
1496 tracing::warn!(
1497 ?e,
1498 "failed to list global in_progress tasks for cross-repo stuck recovery"
1499 );
1500 vec![]
1501 }
1502 };
1503 for task in all_external_in_progress
1504 .iter()
1505 .filter(|t| t.origin != "internal" && !active_repos.contains(&t.repo))
1506 {
1507 let external_id = task
1508 .external_id
1509 .clone()
1510 .unwrap_or_else(|| task.id.to_string());
1511 let Some(timing) = stuck_task_timing_from_map(
1512 tmux,
1513 &task.repo,
1514 &external_id,
1515 &task.updated_at,
1516 config,
1517 "cannot parse updated_at, skipping cross-repo stuck-task check",
1518 session_map,
1519 ) else {
1520 continue;
1521 };
1522
1523 if !timing.has_session {
1527 let dispatch_key = format!("{}/{external_id}", task.repo);
1528 if dispatching.contains_key(&dispatch_key) {
1529 tracing::info!(
1533 task_id = task.id,
1534 repo = %task.repo,
1535 dispatch_key,
1536 age_mins = timing.age.num_minutes(),
1537 "skipping cross-repo stuck-task reclaim: runner still actively processing this task"
1538 );
1539 continue;
1540 }
1541 }
1542
1543 if timing.has_session {
1544 tracing::warn!(
1545 task_id = task.id,
1546 repo = %task.repo,
1547 age_mins = timing.age.num_minutes(),
1548 threshold_mins = timing.threshold / 60,
1549 "recovering cross-repo stuck task: timed out with active session → new"
1550 );
1551 } else {
1552 tracing::warn!(
1553 task_id = task.id,
1554 repo = %task.repo,
1555 dispatch_key = format!("{}/{external_id}", task.repo),
1556 age_mins = timing.age.num_minutes(),
1557 threshold_mins = timing.threshold / 60,
1558 "recovering cross-repo stuck task: no session found — reclaiming early → new"
1559 );
1560 }
1561
1562 if let Err(e) = store_set_by_id(
1563 Some(store),
1564 task.id,
1565 &[
1566 ("agent", serde_json::Value::Null),
1567 ("model", serde_json::Value::Null),
1568 ("route_attempts", serde_json::json!(0)),
1569 ],
1570 )
1571 .await
1572 {
1573 tracing::warn!(
1574 task_id = task.id,
1575 repo = %task.repo,
1576 ?e,
1577 "failed to clear routing fields for cross-repo stuck task — will retry next tick"
1578 );
1579 continue;
1580 }
1581
1582 finalize_open_runs_for_recovery(
1583 store,
1584 task.id,
1585 if timing.has_session {
1586 "stuck-task recovery: cross-repo active session killed"
1587 } else {
1588 "stuck-task recovery: cross-repo no session found"
1589 },
1590 )
1591 .await;
1592
1593 if let Err(e) = store.update_status(task.id, TaskStatus::New).await {
1594 tracing::warn!(
1595 task_id = task.id,
1596 repo = %task.repo,
1597 ?e,
1598 "failed to reset cross-repo stuck task status"
1599 );
1600 continue;
1601 }
1602
1603 let details = serde_json::json!({
1604 "failure_reason": if timing.has_session { "stuck_with_session" } else { "stuck_no_session" },
1605 "age_minutes": timing.age.num_minutes(),
1606 "had_session": timing.has_session,
1607 "scope": "cross_repo_global_sweep",
1608 });
1609 store::store_log_activity(
1610 &Some(Arc::clone(store)),
1611 &task.repo,
1612 &external_id,
1613 "rerouted",
1614 Some("in_progress"),
1615 Some("new"),
1616 None::<&str>,
1617 None::<&str>,
1618 Some(&details),
1619 )
1620 .await;
1621 }
1622}
1623
1624pub(crate) async fn tick_global_sweeps(
1633 task_manager: &Arc<TaskManager>,
1634 store: &Arc<TaskStore>,
1635 router: &Router,
1636 dispatching: &Arc<DashMap<String, String>>,
1637 auto_merge_in_flight: &Arc<DashSet<String>>,
1638) {
1639 if let Err(e) = sync::auto_unblock_ci_failure_blocked_tasks_global(
1644 task_manager,
1645 store,
1646 auto_merge_in_flight,
1647 )
1648 .await
1649 {
1650 tracing::warn!(err = %e, "global CI-failure blocked sweep failed");
1651 }
1652
1653 if let Err(e) = sync::refire_and_escalate_stale_needs_review_global(
1656 task_manager,
1657 store,
1658 router,
1659 dispatching,
1660 )
1661 .await
1662 {
1663 tracing::warn!(err = %e, "global NeedsReview refire/escalation sweep failed");
1664 }
1665
1666 if let Err(e) =
1669 sync::auto_recover_rebroadcast_blocked_tasks_global(router, task_manager, store).await
1670 {
1671 tracing::warn!(err = %e, "global rebroadcast-blocked recovery sweep failed");
1672 }
1673}
1674
1675pub(crate) async fn tick_route_tasks(
1677 backend: &Arc<dyn ExternalBackend>,
1678 task_manager: &Arc<TaskManager>,
1679 router: &mut Router,
1680 store: &Arc<TaskStore>,
1681 repo: &str,
1682 routing_quota: &mut RoutingTickQuota,
1683) -> anyhow::Result<()> {
1684 let _span = tracing::info_span!("engine.tick.phase3a.route").entered();
1685 if let Err(err) = dependencies::apply_claim_gates(store).await {
1686 tracing::warn!(err = %err, "dependency claim gate failed");
1687 }
1688 if routing_quota.is_exhausted() {
1689 tracing::debug!("routing quota exhausted for this tick — skipping repo routing phase");
1690 return Ok(());
1691 }
1692
1693 if is_github_circuit_open() {
1697 let remaining = github_circuit_remaining_secs();
1698 tracing::info!(
1699 remaining_secs = remaining,
1700 "GitHub 5xx circuit breaker open — skipping routing phase"
1701 );
1702 return Ok(());
1703 }
1704 let new_tasks = task_manager.list_routable().await?;
1705 let routable: Vec<&ExternalTask> = new_tasks
1706 .iter()
1707 .filter(|t| !skips_agent(&t.labels))
1708 .collect();
1709
1710 if !routable.is_empty() {
1714 let start = Instant::now();
1715 router.refresh_health(store).await;
1716 tracing::debug!(
1717 duration_ms = start.elapsed().as_millis(),
1718 "router.refresh_health completed"
1719 );
1720 }
1721
1722 for task in routable.into_iter().take(routing_quota.remaining()) {
1724 if !routing_quota.consume_one() {
1725 break;
1726 }
1727 let _task_span = tracing::info_span!("engine.route", task_id = %task.id.0).entered();
1728
1729 let task_start = Instant::now();
1730 match router.route(task, store, repo).await {
1731 Ok(result) => {
1732 tracing::debug!(task_id = %task.id.0, duration_ms = task_start.elapsed().as_millis(), "route completed");
1733 if let Err(e) = router
1735 .store_route_result(&task.id.0, &result, store, repo)
1736 .await
1737 {
1738 tracing::warn!(
1739 task_id = task.id.0,
1740 ?e,
1741 "failed to store route result; skipping Routed transition"
1742 );
1743 continue;
1744 }
1745
1746 if is_internal_id(&task.id.0) {
1747 if let Err(e) = task_manager
1749 .update_task_status(&task.id, Status::Routed)
1750 .await
1751 {
1752 tracing::warn!(
1753 task_id = task.id.0,
1754 ?e,
1755 "failed to set internal status:routed"
1756 );
1757 continue;
1758 }
1759 } else {
1760 let backend_clone = Arc::clone(backend);
1764 let task_id_clone = task.id.clone();
1765 let task_labels: Vec<String> = task.labels.clone();
1766 let new_labels = {
1767 let mut labels = vec![
1768 format!("agent:{}", result.agent),
1769 format!("complexity:{}", result.complexity),
1770 ];
1771 if let Some(ref model) = result.model {
1772 labels.push(format!("model:{model}"));
1773 }
1774 labels
1775 };
1776 tokio::spawn(async move {
1777 for label in &task_labels {
1779 if (label.starts_with("agent:")
1780 || label.starts_with("complexity:")
1781 || label.starts_with("model:"))
1782 && let Err(e) =
1783 backend_clone.remove_label(&task_id_clone, label).await
1784 {
1785 tracing::warn!(task_id = task_id_clone.0, label, error = %e, "failed to remove stale routing label during re-route");
1786 }
1787 }
1788 if let Err(e) = backend_clone.set_labels(&task_id_clone, &new_labels).await
1790 {
1791 tracing::warn!(
1792 task_id = task_id_clone.0,
1793 ?e,
1794 "failed to set routing labels"
1795 );
1796 }
1797 });
1798
1799 if let Err(e) = task_manager
1801 .update_task_status(&task.id, Status::Routed)
1802 .await
1803 {
1804 tracing::warn!(task_id = task.id.0, ?e, "failed to set status:routed");
1805 continue;
1806 }
1807 }
1808
1809 match store.ensure_external_task(repo, task).await {
1811 Ok(store_id) => {
1812 let profile_json =
1813 serde_json::to_string(&result.profile).unwrap_or_default();
1814 let skills_json =
1815 serde_json::to_string(&result.selected_skills).unwrap_or_default();
1816 if let Err(e) = store
1817 .store_route(&crate::store::StoreRoute {
1818 id: store_id,
1819 agent: &result.agent,
1820 model: result.model.as_deref(),
1821 complexity: &result.complexity,
1822 estimate: result.estimate,
1823 reason: &result.reason,
1824 profile: &profile_json,
1825 skills: &skills_json,
1826 })
1827 .await
1828 {
1829 tracing::debug!(
1830 task_id = task.id.0,
1831 ?e,
1832 "dual-write: store_route failed"
1833 );
1834 }
1835 if let Err(e) = store.update_status(store_id, TaskStatus::Routed).await {
1836 tracing::debug!(
1837 task_id = task.id.0,
1838 ?e,
1839 "dual-write: update_status failed"
1840 );
1841 }
1842 if result.estimate > 0 {
1846 let backend_clone = Arc::clone(backend);
1847 let task_id = task.id.clone();
1848 let estimate = result.estimate;
1849 tokio::spawn(async move {
1850 if let Err(e) = backend_clone
1851 .sync_estimate_to_project(&task_id, estimate)
1852 .await
1853 {
1854 tracing::debug!(
1855 task_id = task_id.0,
1856 estimate,
1857 err = %e,
1858 "dual-write: estimate project sync failed"
1859 );
1860 }
1861 });
1862 }
1863 }
1864 Err(e) => {
1865 tracing::debug!(
1866 task_id = task.id.0,
1867 ?e,
1868 "dual-write: ensure_external_task failed"
1869 );
1870 }
1871 }
1872
1873 if let Some(ref warning) = result.warning {
1874 tracing::warn!(task_id = task.id.0, warning, "routing sanity warning");
1875 }
1876
1877 tracing::info!(
1878 task_id = task.id.0,
1879 agent = %result.agent,
1880 complexity = %result.complexity,
1881 reason = %result.reason,
1882 "task routed"
1883 );
1884 }
1885 Err(e) => {
1886 if let Some(cooled) = e.downcast_ref::<AllAgentsCooledError>() {
1887 tracing::error!(
1888 task_id = %task.id.0,
1889 scope = %cooled.scope(),
1890 remaining_secs = ?cooled.remaining_secs(),
1891 degraded = ?cooled.degraded(),
1892 "ALL AGENTS COOLED — no agent available to route this task. \
1893 Task stays in 'new' and will be retried on every tick. \
1894 Check `orch cooldown list` to see what is blocking dispatch."
1895 );
1896 continue;
1897 }
1898 if let Some(waiting) = e.downcast_ref::<WaitingOnDependency>() {
1899 tracing::info!(
1900 task_id = %task.id.0,
1901 blockers = %waiting.blockers.join(", "),
1902 "task is waiting on a dependency"
1903 );
1904 continue;
1905 }
1906 tracing::error!(task_id = task.id.0, ?e, "routing failed, skipping task");
1907 }
1908 }
1909 }
1910 Ok(())
1911}
1912
1913pub(crate) async fn route_new_tasks_global(
1929 router: &mut Router,
1930 store: &Arc<TaskStore>,
1931 active_repos: &HashSet<String>,
1932 routing_quota: &mut RoutingTickQuota,
1933) -> anyhow::Result<()> {
1934 if routing_quota.is_exhausted() {
1935 tracing::debug!("routing quota exhausted for this tick — skipping global routing sweep");
1936 return Ok(());
1937 }
1938
1939 let all_new = store.list_all_by_status_global(TaskStatus::New).await?;
1940 let orphaned: Vec<&Task> = all_new
1941 .iter()
1942 .filter(|t| !active_repos.contains(&t.repo))
1943 .filter(|t| !skips_agent(&t.labels))
1944 .collect();
1945
1946 if orphaned.is_empty() {
1947 return Ok(());
1948 }
1949
1950 tracing::info!(
1951 count = orphaned.len(),
1952 "global routing sweep: found new tasks from inactive repos"
1953 );
1954
1955 router.refresh_health(store).await;
1956
1957 for task in orphaned.into_iter().take(routing_quota.remaining()) {
1958 if !routing_quota.consume_one() {
1959 break;
1960 }
1961 let ext_task = tasks::store_task_to_external(task);
1962 let task_start = Instant::now();
1963 match router.route(&ext_task, store, &task.repo).await {
1964 Ok(result) => {
1965 tracing::debug!(
1966 task_id = %ext_task.id.0,
1967 repo = %task.repo,
1968 duration_ms = task_start.elapsed().as_millis(),
1969 "global sweep: route completed"
1970 );
1971 let profile_json = serde_json::to_string(&result.profile).unwrap_or_default();
1972 let skills_json =
1973 serde_json::to_string(&result.selected_skills).unwrap_or_default();
1974 if let Err(e) = store
1975 .store_route(&StoreRoute {
1976 id: task.id,
1977 agent: &result.agent,
1978 model: result.model.as_deref(),
1979 complexity: &result.complexity,
1980 estimate: result.estimate,
1981 reason: &result.reason,
1982 profile: &profile_json,
1983 skills: &skills_json,
1984 })
1985 .await
1986 {
1987 tracing::warn!(
1988 task_id = %ext_task.id.0,
1989 repo = %task.repo,
1990 err = %e,
1991 "global sweep: failed to store route result; skipping status update"
1992 );
1993 continue;
1994 }
1995 match store.update_status(task.id, TaskStatus::Routed).await {
1996 Err(e) => {
1997 tracing::warn!(
1998 task_id = %ext_task.id.0,
1999 repo = %task.repo,
2000 err = %e,
2001 "global sweep: failed to set status:routed"
2002 );
2003 }
2004 _ => {
2005 tracing::info!(
2006 task_id = %ext_task.id.0,
2007 repo = %task.repo,
2008 agent = %result.agent,
2009 complexity = %result.complexity,
2010 "global sweep: task routed"
2011 );
2012 }
2013 }
2014 if let Some(ref warning) = result.warning {
2015 tracing::warn!(task_id = %ext_task.id.0, warning, "global sweep: routing sanity warning");
2016 }
2017 }
2018 Err(e) => {
2019 if let Some(cooled) = e.downcast_ref::<AllAgentsCooledError>() {
2020 tracing::error!(
2021 task_id = %ext_task.id.0,
2022 repo = %task.repo,
2023 scope = %cooled.scope(),
2024 remaining_secs = ?cooled.remaining_secs(),
2025 degraded = ?cooled.degraded(),
2026 "global sweep: ALL AGENTS COOLED — task stays in 'new'"
2027 );
2028 continue;
2029 }
2030 if let Some(waiting) = e.downcast_ref::<WaitingOnDependency>() {
2031 tracing::info!(
2032 task_id = %ext_task.id.0,
2033 repo = %task.repo,
2034 blockers = %waiting.blockers.join(", "),
2035 "global sweep: task is waiting on a dependency"
2036 );
2037 continue;
2038 }
2039 tracing::error!(
2040 task_id = %ext_task.id.0,
2041 repo = %task.repo,
2042 err = ?e,
2043 "global sweep: routing failed, skipping task"
2044 );
2045 }
2046 }
2047 }
2048
2049 Ok(())
2050}
2051
2052#[derive(Debug, Clone, Copy)]
2054pub(crate) struct DispatchMode {
2055 pub(crate) is_degraded: bool,
2056 pub(crate) healthy_agents: usize,
2057 pub(crate) threshold: usize,
2058}
2059
2060pub(crate) fn dispatch_mode_from_router(router: &Router) -> DispatchMode {
2061 let threshold = router_config::min_healthy_agents_threshold();
2062 let healthy_agents = router.healthy_agent_count("simple");
2063 let is_degraded = router.is_degraded(threshold);
2064 DispatchMode {
2065 is_degraded,
2066 healthy_agents,
2067 threshold,
2068 }
2069}
2070
2071#[expect(
2073 clippy::too_many_arguments,
2074 reason = "each argument is engine state the tick passes down"
2075)]
2076#[tracing::instrument(skip_all, name = "engine.tick.phase3b.dispatch")]
2077pub(crate) async fn tick_dispatch_tasks(
2078 backend: &Arc<dyn ExternalBackend>,
2079 tmux: &Arc<TmuxManager>,
2080 repo: &str,
2081 runner: &Arc<TaskRunner>,
2082 capture: &Arc<CaptureService>,
2083 semaphore: &Arc<Semaphore>,
2084 task_manager: &Arc<TaskManager>,
2085 weight_tx: &mpsc::Sender<WeightSignal>,
2086 dispatch_mode: DispatchMode,
2087 dispatching: &Arc<DashMap<String, String>>,
2088 store: &Arc<TaskStore>,
2089 session_map: &HashMap<String, bool>,
2090) -> anyhow::Result<()> {
2091 let mut routed_tasks = task_manager.list_external_by_status(Status::Routed).await?;
2093
2094 let internal_routed = task_manager
2096 .list_internal_by_status(DbStatus::Routed)
2097 .await?;
2098 routed_tasks.extend(internal_routed);
2099
2100 let dispatchable: Vec<ExternalTask> = routed_tasks
2102 .into_iter()
2103 .filter(|t| !skips_agent(&t.labels))
2104 .collect();
2105
2106 dispatch_tasks_for_repo(
2107 backend,
2108 tmux,
2109 repo,
2110 runner,
2111 capture,
2112 semaphore,
2113 task_manager,
2114 weight_tx,
2115 dispatch_mode,
2116 dispatching,
2117 store,
2118 session_map,
2119 dispatchable,
2120 )
2121 .await
2122}
2123
2124#[expect(
2130 clippy::too_many_arguments,
2131 reason = "each argument is engine state the tick passes down"
2132)]
2133async fn dispatch_tasks_for_repo(
2134 backend: &Arc<dyn ExternalBackend>,
2135 tmux: &Arc<TmuxManager>,
2136 repo: &str,
2137 runner: &Arc<TaskRunner>,
2138 capture: &Arc<CaptureService>,
2139 semaphore: &Arc<Semaphore>,
2140 task_manager: &Arc<TaskManager>,
2141 weight_tx: &mpsc::Sender<WeightSignal>,
2142 dispatch_mode: DispatchMode,
2143 dispatching: &Arc<DashMap<String, String>>,
2144 store: &Arc<TaskStore>,
2145 session_map: &HashMap<String, bool>,
2146 dispatchable: Vec<ExternalTask>,
2147) -> anyhow::Result<()> {
2148 if dispatchable.is_empty() {
2149 tracing::debug!(count = 0, "dispatchable tasks found");
2150 return Ok(());
2151 }
2152
2153 tracing::info!(count = dispatchable.len(), "dispatchable tasks found");
2154 if dispatch_mode.is_degraded {
2155 tracing::warn!(
2156 healthy_agents = dispatch_mode.healthy_agents,
2157 threshold = dispatch_mode.threshold,
2158 "degraded mode: using sequential dispatch"
2159 );
2160 }
2161
2162 let sequential_delay = if dispatch_mode.is_degraded {
2163 router_config::sequential_dispatch_delay_ms()
2164 } else {
2165 0
2166 };
2167
2168 let mut ranked: Vec<(Priority, ExternalTask)> = Vec::with_capacity(dispatchable.len());
2169 for task in dispatchable {
2170 let has_pr = match store.resolve_id_by_external(repo, &task.id.0).await {
2171 Ok(Some(id)) => store.get(id).await.is_ok_and(|t| t.pr_number.is_some()),
2172 _ => false,
2173 };
2174 ranked.push((Priority::from_labels(&task.labels).with_pr(has_pr), task));
2175 }
2176 priority::sort_by_priority(&mut ranked, |t| t.0);
2177
2178 for (idx, (priority, task)) in ranked.into_iter().enumerate() {
2179 if dependencies::dispatch_is_waiting(store, repo, &task.id.0).await {
2180 continue;
2181 }
2182 if idx > 0 && dispatch_mode.is_degraded {
2184 let delay_ms = sequential_delay;
2185 tracing::debug!(
2186 task_id = task.id.0,
2187 delay_ms = delay_ms,
2188 "sequential dispatch: waiting before dispatch"
2189 );
2190 time::sleep(Duration::from_millis(delay_ms)).await;
2191 }
2192 let dispatch_key = format!("{}/{}", repo, task.id.0);
2199 {
2204 match dispatching.entry(dispatch_key.clone()) {
2205 Entry::Occupied(existing) => {
2206 let existing_id = existing.get().clone();
2207 drop(existing); if existing_id == task.id.0 {
2209 tracing::debug!(
2210 task_id = task.id.0,
2211 "task already dispatching, skipping duplicate"
2212 );
2213 } else {
2214 tracing::warn!(
2215 task_id = task.id.0,
2216 existing_task_id = existing_id,
2217 dispatch_key,
2218 "dispatch key collision: unexpected task already holds this key"
2219 );
2220 }
2221 continue;
2222 }
2223 Entry::Vacant(slot) => {
2224 slot.insert(task.id.0.clone());
2225 tracing::info!(task_id = task.id.0, dispatch_key, "dispatch guard claimed");
2232 }
2233 }
2234 }
2235 let dispatch_guard = DispatchGuard::new(Arc::clone(dispatching), dispatch_key.clone());
2237 let task_id = task.id.0.clone();
2238
2239 let route_result = match get_route_result(store, repo, &task_id).await {
2242 Ok(r) => Some(r),
2243 Err(RouteResultError::NoAgent { .. }) => {
2244 tracing::warn!(
2245 task_id,
2246 "routed task missing agent — resetting to new for re-routing (#1604)"
2247 );
2248 if let Err(e2) = task_manager.update_task_status(&task.id, Status::New).await {
2249 tracing::error!(task_id, error = %e2, "failed to reset task to new");
2250 }
2251 continue; }
2253 Err(e) => {
2254 tracing::warn!(task_id, error = %e, "get_route_result failed — dispatching without route info");
2255 None
2256 }
2257 };
2258
2259 if let Some(route) = route_result.as_ref() {
2265 let agent_cooled = is_agent_in_cooldown(&route.agent);
2266 let model_cooled = route
2267 .model
2268 .as_deref()
2269 .is_some_and(|model| is_model_in_cooldown(&route.agent, model));
2270 if agent_cooled || model_cooled {
2271 tracing::warn!(
2272 task_id,
2273 agent = route.agent,
2274 model = route.model.as_deref().unwrap_or(""),
2275 "routed agent/model entered cooldown after routing — deferring dispatch to next tick"
2276 );
2277 continue; }
2279 }
2280
2281 let session_name = tmux.session_name(repo, &task_id);
2283 if tmux
2284 .session_blocks_dispatch_from_map(&session_name, session_map)
2285 .await
2286 {
2287 tracing::warn!(
2288 task_id,
2289 session_name,
2290 "task has existing tmux session, skipping dispatch"
2291 );
2292 continue; }
2294
2295 let Some(permit) = priority::acquire_dispatch_slot(semaphore, priority) else {
2297 tracing::debug!("all parallel slots busy, skipping remaining tasks");
2298 break; };
2300
2301 let set_in_progress_result = task_manager
2303 .update_task_status(&task.id, Status::InProgress)
2304 .await;
2305 if let Err(e) = set_in_progress_result {
2306 tracing::error!(task_id, ?e, "failed to set in_progress, skipping dispatch");
2307 drop(permit);
2308 continue; }
2310 tracing::info!(task_id, "dispatching task");
2311
2312 capture
2314 .register_session(repo, &task_id, &session_name)
2315 .await;
2316
2317 let runner = Arc::clone(runner);
2319 let backend = Arc::clone(backend);
2320 let tmux = Arc::clone(tmux);
2321 let capture = Arc::clone(capture);
2322 let task_id_for_cleanup = task_id.clone();
2323 let task_owned = task;
2324 let weight_tx = weight_tx.clone();
2325 let repo_owned = repo.to_string();
2326 let task_manager_for_spawn = Arc::clone(task_manager);
2327 let store_for_spawn = Arc::clone(store);
2328
2329 let repo_ctx = repo_owned.clone();
2330 tokio::spawn(REPO_CONTEXT.scope(repo_ctx, async move {
2331 let _dispatch_guard = dispatch_guard; let dispatch_start = Instant::now();
2333 match runner
2334 .run_with_context(&task_owned, &backend, &tmux, route_result.as_ref())
2335 .await
2336 {
2337 Ok(signal) => {
2338 tracing::info!(task_id, "task runner completed");
2339
2340 let duration = dispatch_start.elapsed().as_secs_f64();
2341
2342 let enable_review = config::get("workflow.enable_review_agent")
2348 .map_or(true, |v| v != "false");
2349 let has_pr = if enable_review && matches!(signal, WeightSignal::Success { .. })
2351 {
2352 match store_for_spawn.resolve_task_id(&repo_owned, &task_id).await {
2353 Ok(Some(store_id)) => match store_for_spawn.get(store_id).await {
2354 Ok(t) => t.pr_number.is_some(),
2355 Err(e) => {
2356 tracing::warn!(task_id, err = %e, "store.get() failed — defaulting to needs_review");
2357 true
2358 }
2359 },
2360 Ok(None) => false,
2361 Err(e) => {
2362 tracing::warn!(task_id, err = %e, "failed to check PR status — defaulting to needs_review");
2363 true
2364 }
2365 }
2366 } else {
2367 false
2368 };
2369 let display_status = display_status_for(&signal, enable_review, has_pr);
2370
2371 if display_status == "done" {
2372 tracing::info!(
2373 task_id,
2374 "marking done: the run succeeded with no PR to review"
2375 );
2376 }
2377 tracing::info!(task_id, display_status, "task run finished, applying status");
2378
2379 let _ = weight_tx.send(signal).await;
2381
2382 if display_status == "needs_review" {
2386 if let Err(e) = task_manager_for_spawn
2389 .update_task_status_with_duration(
2390 &ExternalId(task_id.clone()),
2391 Status::NeedsReview,
2392 Some(duration),
2393 )
2394 .await
2395 {
2396 tracing::error!(task_id, err = %e, "update_task_status(NeedsReview) failed — task may be stuck");
2397 }
2398 } else {
2400 let final_status = match display_status {
2402 "done" => Status::Done,
2403 "blocked" => Status::Blocked,
2404 _ => Status::New,
2405 };
2406 if let Err(e) = task_manager_for_spawn
2407 .update_task_status_with_duration(
2408 &ExternalId(task_id.clone()),
2409 final_status,
2410 Some(duration),
2411 )
2412 .await
2413 {
2414 tracing::warn!(task_id, ?e, "failed to update task status after completion");
2415 }
2416 }
2417 }
2418 Err(e) => {
2419 tracing::error!(task_id, ?e, "task runner failed");
2420 let duration = dispatch_start.elapsed().as_secs_f64();
2421 if is_internal_id(&task_id) {
2422 if let Err(ue) = task_manager_for_spawn
2424 .update_task_status_with_duration(
2425 &ExternalId(task_id.clone()),
2426 Status::NeedsReview,
2427 Some(duration),
2428 )
2429 .await
2430 {
2431 tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
2432 }
2433 } else {
2434 if let Err(comment_err) = backend
2436 .post_comment(
2437 &ExternalId(task_id.clone()),
2438 &format!(
2439 "[{}] error: task runner failed: {e}{}",
2440 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
2441 engine::orch_footer()
2442 ),
2443 )
2444 .await
2445 {
2446 tracing::warn!(
2447 task_id,
2448 ?comment_err,
2449 "failed to post error comment to GitHub"
2450 );
2451 }
2452 if let Err(ue) = task_manager_for_spawn
2456 .update_task_status_with_duration(
2457 &ExternalId(task_id.clone()),
2458 Status::NeedsReview,
2459 Some(duration),
2460 )
2461 .await
2462 {
2463 tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
2464 }
2465 }
2466 }
2467 }
2468
2469 capture.unregister_session(&repo_owned, &task_id_for_cleanup).await;
2471
2472 drop(permit);
2474 }));
2476 }
2477 Ok(())
2478}
2479
2480fn skips_agent(labels: &[String]) -> bool {
2481 labels
2482 .iter()
2483 .any(|label| label == "no-agent" || label == "owner:human")
2484}
2485
2486fn group_orphaned_routed_tasks(
2493 all_routed: Vec<Task>,
2494 active_repos: &HashSet<String>,
2495) -> HashMap<String, Vec<Task>> {
2496 let mut by_repo: HashMap<String, Vec<Task>> = HashMap::new();
2497 for task in all_routed
2498 .into_iter()
2499 .filter(|t| !active_repos.contains(&t.repo))
2500 .filter(|t| !skips_agent(&t.labels))
2501 {
2502 by_repo.entry(task.repo.clone()).or_default().push(task);
2503 }
2504 by_repo
2505}
2506
2507#[expect(
2526 clippy::too_many_arguments,
2527 reason = "each argument is engine state the tick passes down"
2528)]
2529pub(crate) async fn dispatch_routed_tasks_global(
2530 tmux: &Arc<TmuxManager>,
2531 capture: &Arc<CaptureService>,
2532 semaphore: &Arc<Semaphore>,
2533 weight_tx: &mpsc::Sender<WeightSignal>,
2534 dispatch_mode: DispatchMode,
2535 dispatching: &Arc<DashMap<String, String>>,
2536 store: &Arc<TaskStore>,
2537 session_map: &HashMap<String, bool>,
2538 active_repos: &HashSet<String>,
2539) -> anyhow::Result<()> {
2540 let all_routed = store.list_all_by_status_global(TaskStatus::Routed).await?;
2541 let by_repo = group_orphaned_routed_tasks(all_routed, active_repos);
2542
2543 if by_repo.is_empty() {
2544 return Ok(());
2545 }
2546
2547 tracing::info!(
2548 repos = by_repo.len(),
2549 "global dispatch sweep: found routed tasks from inactive repos"
2550 );
2551
2552 for (repo, tasks) in by_repo {
2553 let backend: Arc<dyn ExternalBackend> = match GitHubBackend::new(repo.clone()) {
2554 Ok(b) => Arc::new(b),
2555 Err(e) => {
2556 tracing::warn!(repo = %repo, err = %e, "global sweep: failed to construct backend, skipping repo");
2557 continue;
2558 }
2559 };
2560 let task_manager = Arc::new(TaskManager::with_store(
2561 Arc::clone(&backend),
2562 Arc::clone(store),
2563 repo.clone(),
2564 ));
2565 let runner = Arc::new(TaskRunner::new(repo.clone()).with_store(Arc::clone(store)));
2566 let dispatchable: Vec<ExternalTask> =
2567 tasks.iter().map(tasks::store_task_to_external).collect();
2568
2569 if let Err(e) = dispatch_tasks_for_repo(
2570 &backend,
2571 tmux,
2572 &repo,
2573 &runner,
2574 capture,
2575 semaphore,
2576 &task_manager,
2577 weight_tx,
2578 dispatch_mode,
2579 dispatching,
2580 store,
2581 session_map,
2582 dispatchable,
2583 )
2584 .await
2585 {
2586 tracing::warn!(repo = %repo, err = %e, "global sweep: dispatch failed for repo");
2587 }
2588 }
2589
2590 Ok(())
2591}
2592
2593pub(crate) async fn tick_unblock_parents(
2595 backend: &Arc<dyn ExternalBackend>,
2596 task_manager: &Arc<TaskManager>,
2597 store: &Arc<TaskStore>,
2598 repo: &str,
2599) -> anyhow::Result<()> {
2600 let blocked = task_manager.list_all_by_status(Status::Blocked).await?;
2601
2602 let store_blocked = store.list_by_status(repo, TaskStatus::Blocked).await?;
2606 let has_block_reason: HashSet<String> = store_blocked
2607 .iter()
2608 .filter(|t| t.block_reason.is_some())
2609 .filter_map(|t| t.external_id.clone())
2610 .collect();
2611
2612 let handles: Vec<_> = blocked
2616 .into_iter()
2617 .filter(|task| {
2618 if has_block_reason.contains(&task.id.0) {
2619 tracing::debug!(
2620 task_id = task.id.0,
2621 "skipping blocked task with known block_reason"
2622 );
2623 false
2624 } else {
2625 true
2626 }
2627 })
2628 .map(|task| {
2629 let backend_clone = Arc::clone(backend);
2630 let task_manager_clone = Arc::clone(task_manager);
2631 let store_clone = Arc::clone(store);
2632 let repo_owned = repo.to_string();
2633 tokio::spawn(async move {
2634 let task_id = &task.id;
2635
2636 let mut children: Vec<ExternalId> = Vec::new();
2637 let mut child_statuses: HashMap<
2638 String,
2639 TaskStatus,
2640 > = HashMap::new();
2641
2642 match store_clone.resolve_task_id(&repo_owned, &task_id.0).await {
2643 Ok(Some(parent_store_id)) => {
2644 match store_clone.list_children(&repo_owned, parent_store_id).await {
2645 Ok(store_children) => {
2646 for child in store_children {
2647 let Some(child_external_id) = child.external_id else {
2648 continue;
2649 };
2650 child_statuses.insert(child_external_id.clone(), child.status);
2651 children.push(ExternalId(child_external_id));
2652 }
2653 }
2654 Err(e) => {
2655 tracing::debug!(
2656 task_id = task_id.0,
2657 ?e,
2658 "failed to list store-linked children"
2659 );
2660 }
2661 }
2662 }
2663 Ok(None) => {}
2664 Err(e) => {
2665 tracing::debug!(
2666 task_id = task_id.0,
2667 ?e,
2668 "failed to resolve blocked task in store for child lookup"
2669 );
2670 }
2671 }
2672
2673 if !is_internal_id(&task_id.0) {
2674 match backend_clone.get_sub_issues(task_id).await {
2675 Ok(ids) => {
2676 let mut seen: HashSet<String> =
2677 children.iter().map(|child| child.0.clone()).collect();
2678 for id in ids {
2679 if seen.insert(id.0.clone()) {
2680 children.push(id);
2681 }
2682 }
2683 }
2684 Err(e) => {
2685 tracing::debug!(task_id = task_id.0, ?e, "failed to get sub-issues");
2686 }
2687 }
2688 }
2689
2690 if children.is_empty() {
2692 return;
2693 }
2694
2695 let mut all_done = true;
2698 let child_ext_strs: Vec<String> =
2700 children.iter().map(|c| c.0.clone()).collect();
2701 let child_exts: Vec<&str> =
2702 child_ext_strs.iter().map(String::as_str).collect();
2703 match store_clone
2705 .get_statuses_by_external_ids(&repo_owned, &child_exts)
2706 .await
2707 { Ok(map) => {
2708 for (k, v) in map {
2709 child_statuses.insert(k, v);
2710 }
2711 } _ => {
2712 tracing::debug!(
2713 task_id = task_id.0,
2714 "failed to batch-query store for child statuses; falling back to per-child lookups"
2715 );
2716 }}
2717
2718 for child_id in &children {
2719 if let Some(status) = child_statuses.get(&child_id.0) {
2721 if *status != TaskStatus::Done {
2722 all_done = false;
2723 break;
2724 }
2725 continue;
2726 }
2727
2728 match backend_clone.get_task(child_id).await {
2730 Ok(child) => {
2731 if !child.labels.iter().any(|l| l == Status::Done.as_label()) {
2732 all_done = false;
2733 break;
2734 }
2735 }
2736 Err(e) => {
2737 tracing::debug!(task_id = task_id.0, child = %child_id.0, ?e, "failed to fetch child task from backend — treating as not done");
2738 all_done = false;
2739 break;
2740 }
2741 }
2742 }
2743
2744 if all_done {
2745 tracing::info!(
2746 task_id = task_id.0,
2747 children = children.len(),
2748 "all children done, unblocking parent"
2749 );
2750 if let Err(e) =
2751 task_manager_clone.update_task_status(task_id, Status::New).await
2752 {
2753 tracing::warn!(task_id = task_id.0, ?e, "failed to unblock parent");
2754 }
2755 }
2756 })
2757 })
2758 .collect();
2759
2760 for handle in handles {
2761 if let Err(e) = handle.await {
2762 tracing::warn!(?e, "phase 4 sub-issue check panicked");
2763 }
2764 }
2765 Ok(())
2766}
2767
2768pub(crate) async fn tick_job_scheduler(
2770 jobs_path: &Path,
2771 backend: &Arc<dyn ExternalBackend>,
2772 store: Option<&Arc<TaskStore>>,
2773 repo: &str,
2774 transport: Option<&Arc<Transport>>,
2775) -> anyhow::Result<()> {
2776 jobs::tick(jobs_path, backend, store, repo, transport).await
2777}
2778
2779#[expect(
2789 clippy::too_many_arguments,
2790 reason = "each argument is engine state the tick passes down"
2791)]
2792pub(crate) async fn tick_before_routing(
2793 backend: &Arc<dyn ExternalBackend>,
2794 tmux: &Arc<TmuxManager>,
2795 repo: &str,
2796 capture: &Arc<CaptureService>,
2797 config: &EngineConfig,
2798 task_manager: &Arc<TaskManager>,
2799 dispatching: &Arc<DashMap<String, String>>,
2800 store: &Arc<TaskStore>,
2801 session_map: &HashMap<String, bool>,
2802) {
2803 let mut phases: Vec<(&str, u128)> = Vec::new();
2804 let mut at = Instant::now();
2805
2806 if let Err(e) = tick_check_session_completions(tmux, repo, capture, store).await {
2807 tracing::warn!(err = %e, "session completion phase failed");
2808 }
2809 phases.push(("sessions", at.elapsed().as_millis()));
2810
2811 if !store.is_leader() {
2814 super::log_phase_timings("tick.pre_routing", repo, &phases);
2815 return;
2816 }
2817
2818 at = Instant::now();
2819 if let Err(e) =
2820 tick_detect_silent_agents(tmux, repo, capture, backend, task_manager, config, store).await
2821 {
2822 tracing::warn!(err = %e, "silent-agent detection phase failed");
2823 }
2824 phases.push(("silent_agents", at.elapsed().as_millis()));
2825
2826 at = Instant::now();
2827 if let Err(e) = tick_recover_stuck_tasks(
2828 backend,
2829 tmux,
2830 repo,
2831 task_manager,
2832 config,
2833 store,
2834 session_map,
2835 dispatching,
2836 )
2837 .await
2838 {
2839 tracing::warn!(err = %e, "stuck-task recovery phase failed");
2840 }
2841 phases.push(("stuck_tasks", at.elapsed().as_millis()));
2842
2843 super::log_phase_timings("tick.pre_routing", repo, &phases);
2844}
2845
2846#[expect(
2854 clippy::too_many_arguments,
2855 reason = "each argument is engine state the tick passes down"
2856)]
2857pub(crate) async fn tick_after_routing(
2858 backend: &Arc<dyn ExternalBackend>,
2859 tmux: &Arc<TmuxManager>,
2860 repo: &str,
2861 runner: &Arc<TaskRunner>,
2862 capture: &Arc<CaptureService>,
2863 semaphore: &Arc<Semaphore>,
2864 project_dir: &Path,
2865 task_manager: &Arc<TaskManager>,
2866 weight_tx: &mpsc::Sender<WeightSignal>,
2867 dispatch_mode: DispatchMode,
2868 dispatching: &Arc<DashMap<String, String>>,
2869 store: &Arc<TaskStore>,
2870 transport: Option<&Arc<Transport>>,
2871 session_map: &HashMap<String, bool>,
2872) {
2873 let mut phases: Vec<(&str, u128)> = Vec::new();
2874 let mut at = Instant::now();
2875
2876 if let Err(e) = tick_dispatch_tasks(
2877 backend,
2878 tmux,
2879 repo,
2880 runner,
2881 capture,
2882 semaphore,
2883 task_manager,
2884 weight_tx,
2885 dispatch_mode,
2886 dispatching,
2887 store,
2888 session_map,
2889 )
2890 .await
2891 {
2892 tracing::warn!(err = %e, "dispatch phase failed");
2893 }
2894 phases.push(("dispatch", at.elapsed().as_millis()));
2895
2896 at = Instant::now();
2897 if let Err(e) = tick_unblock_parents(backend, task_manager, store, repo).await {
2898 tracing::warn!(err = %e, "parent-unblock phase failed");
2899 }
2900 phases.push(("unblock_parents", at.elapsed().as_millis()));
2901
2902 at = Instant::now();
2903 let jobs_path = project_dir.join(".orch.yml");
2904 if let Err(e) = tick_job_scheduler(&jobs_path, backend, Some(store), repo, transport).await {
2905 tracing::error!(?e, "job scheduler tick failed");
2906 }
2907 phases.push(("jobs", at.elapsed().as_millis()));
2908
2909 super::log_phase_timings("tick.post_routing", repo, &phases);
2910}
2911
2912#[expect(
2922 clippy::too_many_arguments,
2923 reason = "each argument is engine state the tick passes down"
2924)]
2925pub(crate) async fn tick_all(
2926 engines: &[ProjectEngine],
2927 tmux: &Arc<TmuxManager>,
2928 capture: &Arc<CaptureService>,
2929 semaphore: &Arc<Semaphore>,
2930 config: &EngineConfig,
2931 router: &Arc<RwLock<Router>>,
2932 weight_tx: &mpsc::Sender<WeightSignal>,
2933 dispatching: &Arc<DashMap<String, String>>,
2934 transport: Option<&Arc<Transport>>,
2935 active_repos: &HashSet<String>,
2936 auto_merge_in_flight: &Arc<DashSet<String>>,
2937) {
2938 let _tick_span = tracing::info_span!("engine.tick").entered();
2939
2940 let Some(first) = engines.first() else {
2941 return;
2942 };
2943 let store = &first.store;
2946
2947 for (project, call_type, calls) in http::drain_call_counts().into_iter().take(10) {
2949 tracing::debug!(
2950 project,
2951 call_type,
2952 calls,
2953 "github api calls since last tick"
2954 );
2955 }
2956
2957 let session_map = tmux.batch_session_active().await;
2959
2960 let mut passes = Vec::with_capacity(engines.len());
2961 for engine in engines {
2962 passes.push(REPO_CONTEXT.scope(
2963 engine.repo.clone(),
2964 tick_before_routing(
2965 &engine.backend,
2966 tmux,
2967 &engine.repo,
2968 capture,
2969 config,
2970 &engine.task_manager,
2971 dispatching,
2972 &engine.store,
2973 &session_map,
2974 ),
2975 ));
2976 }
2977 future::join_all(passes).await;
2978
2979 if !store.is_leader() {
2980 return;
2981 }
2982
2983 let at = Instant::now();
2987 {
2988 let router_guard = router.read().await;
2989 tick_global_sweeps(
2990 &first.task_manager,
2991 store,
2992 &router_guard,
2993 dispatching,
2994 auto_merge_in_flight,
2995 )
2996 .await;
2997 }
2998 let sweeps_ms = at.elapsed().as_millis();
2999
3000 let at = Instant::now();
3001 recover_untracked_repo_stuck_tasks(
3002 tmux,
3003 config,
3004 store,
3005 &session_map,
3006 dispatching,
3007 active_repos,
3008 )
3009 .await;
3010 let untracked_ms = at.elapsed().as_millis();
3011
3012 let at = Instant::now();
3013 if let Err(err) = dependencies::reconcile_dependencies(
3014 first.backend.as_ref(),
3015 store,
3016 transport.map(AsRef::as_ref),
3017 )
3018 .await
3019 {
3020 tracing::warn!(err = %err, "dependency reconcile failed");
3021 }
3022 super::log_phase_timings(
3023 "tick.global",
3024 &first.repo,
3025 &[
3026 ("global_sweeps", sweeps_ms),
3027 ("untracked_stuck", untracked_ms),
3028 ("dependencies", at.elapsed().as_millis()),
3029 ],
3030 );
3031
3032 {
3033 let mut router_guard = router.write().await;
3034 let mut routing_quota = RoutingTickQuota::new(router_config::max_tasks_per_routing_tick());
3035 for engine in engines {
3036 let at = Instant::now();
3037 REPO_CONTEXT
3038 .scope(engine.repo.clone(), async {
3039 if let Err(e) = tick_route_tasks(
3040 &engine.backend,
3041 &engine.task_manager,
3042 &mut router_guard,
3043 &engine.store,
3044 &engine.repo,
3045 &mut routing_quota,
3046 )
3047 .await
3048 {
3049 tracing::warn!(err = %e, "routing phase failed");
3050 }
3051 })
3052 .await;
3053 super::log_phase_timings(
3054 "tick.routing",
3055 &engine.repo,
3056 &[("route", at.elapsed().as_millis())],
3057 );
3058 }
3059
3060 if let Err(e) =
3064 route_new_tasks_global(&mut router_guard, store, active_repos, &mut routing_quota).await
3065 {
3066 tracing::warn!(err = %e, "global new-task routing sweep failed");
3067 }
3068 }
3069
3070 let dispatch_mode = dispatch_mode_from_router(&*router.read().await);
3071
3072 let session_map = tmux.batch_session_active().await;
3075
3076 let mut passes = Vec::with_capacity(engines.len());
3077 for engine in engines {
3078 passes.push(REPO_CONTEXT.scope(
3079 engine.repo.clone(),
3080 tick_after_routing(
3081 &engine.backend,
3082 tmux,
3083 &engine.repo,
3084 &engine.runner,
3085 capture,
3086 semaphore,
3087 &engine.project_dir,
3088 &engine.task_manager,
3089 weight_tx,
3090 dispatch_mode,
3091 dispatching,
3092 &engine.store,
3093 transport,
3094 &session_map,
3095 ),
3096 ));
3097 }
3098 future::join_all(passes).await;
3099
3100 if let Err(e) = dispatch_routed_tasks_global(
3106 tmux,
3107 capture,
3108 semaphore,
3109 weight_tx,
3110 dispatch_mode,
3111 dispatching,
3112 store,
3113 &session_map,
3114 active_repos,
3115 )
3116 .await
3117 {
3118 tracing::warn!(err = %e, "global routed-task dispatch sweep failed");
3119 }
3120}
3121
3122pub(crate) fn display_status_for(
3125 signal: &WeightSignal,
3126 enable_review: bool,
3127 has_pr: bool,
3128) -> &'static str {
3129 match signal {
3130 WeightSignal::Success { .. } if enable_review => {
3131 if has_pr {
3132 "needs_review"
3133 } else {
3134 "done"
3135 }
3136 }
3137 WeightSignal::RateLimited { .. } | WeightSignal::Rerouted => "new",
3140 WeightSignal::Blocked => "blocked",
3141 WeightSignal::Failed { .. } => "needs_review",
3143 WeightSignal::None if enable_review => "needs_review",
3145 WeightSignal::Success { .. } | WeightSignal::None => "done",
3146 }
3147}
3148
3149#[cfg(test)]
3150mod tests {
3151 use super::*;
3152 use crate::backends::{ExternalId, ExternalTask, Mention, Status};
3153 use crate::channels::transport::Transport;
3154 use crate::engine::RunTimeouts;
3155 use crate::engine::cooldown;
3156 use crate::engine::router::Router;
3157 use crate::engine::router::RouterConfig;
3158 use crate::engine::tasks::TaskManager;
3159 use crate::store::DepSource;
3160 use crate::store::NewDependency;
3161 use crate::store::NewTask;
3162 use crate::store::StoreRoute;
3163 use crate::store::TaskStatus as DbStatus;
3164 use crate::store::TaskStatus;
3165 use crate::store::TaskStore;
3166 use crate::store::UpsertExternal;
3167 use crate::tmux::{SessionGuard, TmuxManager};
3168 use async_trait::async_trait;
3169 use dashmap::mapref::entry::Entry;
3170 use std::fs;
3171 use std::path::PathBuf;
3172 use std::sync::{Arc, Mutex};
3173 use tokio::sync::RwLock;
3174 use tokio::sync::oneshot;
3175 use tokio::task;
3176
3177 #[test]
3178 fn timed_out_run_without_pr_never_becomes_done() {
3179 let failed = WeightSignal::Failed {
3180 agent: "claude".to_string(),
3181 };
3182 assert_eq!(display_status_for(&failed, true, false), "needs_review");
3183 assert_eq!(display_status_for(&failed, false, false), "needs_review");
3184 }
3185
3186 #[test]
3187 fn done_needs_a_successful_run_with_no_pr() {
3188 let ok = WeightSignal::Success {
3189 agent: "claude".to_string(),
3190 };
3191 assert_eq!(display_status_for(&ok, true, true), "needs_review");
3192 assert_eq!(display_status_for(&ok, true, false), "done");
3193 assert_eq!(
3194 display_status_for(&WeightSignal::Rerouted, true, false),
3195 "new"
3196 );
3197 assert_eq!(
3198 display_status_for(&WeightSignal::None, true, false),
3199 "needs_review"
3200 );
3201 }
3202 #[test]
3203 fn skips_agent_for_human_and_no_agent_labels() {
3204 assert!(skips_agent(&["owner:human".to_string()]));
3205 assert!(skips_agent(&["no-agent".to_string()]));
3206 assert!(!skips_agent(&["status:new".to_string()]));
3207 }
3208
3209 fn cooled_review_router_for_test() -> Router {
3213 Router::new_for_test(RouterConfig::default(), vec!["claude".to_string()])
3214 }
3215
3216 struct MockBackend {
3220 blocked_tasks: Vec<ExternalTask>,
3222 sub_issues: HashMap<String, Vec<ExternalId>>,
3224 tasks_by_id: HashMap<String, ExternalTask>,
3226 get_sub_issues_calls: Arc<Mutex<Vec<String>>>,
3228 status_updates: Arc<Mutex<Vec<(String, Status)>>>,
3230 removed_labels: Arc<Mutex<Vec<(String, String)>>>,
3232 posted_comments: Arc<Mutex<Vec<(String, String)>>>,
3234 }
3235
3236 impl MockBackend {
3237 fn new() -> Self {
3238 Self {
3239 blocked_tasks: vec![],
3240 sub_issues: HashMap::new(),
3241 tasks_by_id: HashMap::new(),
3242 get_sub_issues_calls: Arc::new(Mutex::new(vec![])),
3243 status_updates: Arc::new(Mutex::new(vec![])),
3244 removed_labels: Arc::new(Mutex::new(vec![])),
3245 posted_comments: Arc::new(Mutex::new(vec![])),
3246 }
3247 }
3248 }
3249
3250 fn make_task(id: &str, labels: &[&str]) -> ExternalTask {
3251 ExternalTask {
3252 id: ExternalId(id.to_string()),
3253 title: format!("Task {id}"),
3254 body: String::new(),
3255 state: "open".to_string(),
3256 labels: labels.iter().map(ToString::to_string).collect(),
3257 author: "bot".to_string(),
3258 created_at: "2026-01-01T00:00:00Z".to_string(),
3259 updated_at: "2026-01-01T00:00:00Z".to_string(),
3260 url: format!("https://github.com/test/test/issues/{id}"),
3261 }
3262 }
3263
3264 #[async_trait]
3265 impl ExternalBackend for MockBackend {
3266 fn name(&self) -> &'static str {
3267 "mock"
3268 }
3269 async fn create_task(
3270 &self,
3271 _title: &str,
3272 _body: &str,
3273 _labels: &[String],
3274 ) -> anyhow::Result<ExternalId> {
3275 Ok(ExternalId("new".to_string()))
3276 }
3277 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
3278 self.tasks_by_id
3279 .get(&id.0)
3280 .cloned()
3281 .ok_or_else(|| anyhow::anyhow!("task not found: {}", id.0))
3282 }
3283 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
3284 if status == Status::Blocked {
3285 Ok(self.blocked_tasks.clone())
3286 } else {
3287 Ok(vec![])
3288 }
3289 }
3290 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
3291 Ok(vec![])
3292 }
3293 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
3294 self.posted_comments
3295 .lock()
3296 .unwrap()
3297 .push((_id.0.clone(), _body.to_string()));
3298 Ok(())
3299 }
3300 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
3301 Ok(())
3302 }
3303 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
3304 self.removed_labels
3305 .lock()
3306 .unwrap()
3307 .push((_id.0.clone(), _label.to_string()));
3308 Ok(())
3309 }
3310 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
3311 self.get_sub_issues_calls.lock().unwrap().push(id.0.clone());
3312 Ok(self.sub_issues.get(&id.0).cloned().unwrap_or_default())
3313 }
3314 async fn create_sub_task(
3315 &self,
3316 _parent: &ExternalId,
3317 _title: &str,
3318 _body: &str,
3319 _labels: &[String],
3320 ) -> anyhow::Result<ExternalId> {
3321 Ok(ExternalId("child".to_string()))
3322 }
3323 async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
3324 Ok(())
3325 }
3326 async fn has_open_issue_with_title(
3327 &self,
3328 _title: &str,
3329 _label: &str,
3330 ) -> anyhow::Result<bool> {
3331 Ok(false)
3332 }
3333 async fn health_check(&self) -> anyhow::Result<()> {
3334 Ok(())
3335 }
3336 async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
3337 Ok(false)
3338 }
3339 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
3340 Ok(Some("testbot".to_string()))
3341 }
3342 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
3343 Ok(vec![])
3344 }
3345 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
3346 self.status_updates
3347 .lock()
3348 .unwrap()
3349 .push((id.0.clone(), status));
3350 Ok(())
3351 }
3352 }
3353
3354 fn make_task_manager(backend: Arc<dyn ExternalBackend>) -> Arc<TaskManager> {
3355 Arc::new(TaskManager::new(backend))
3356 }
3357
3358 #[tokio::test]
3361 async fn unblock_parents_unblocks_when_all_children_done() {
3362 let mut mock = MockBackend::new();
3363
3364 let parent = make_task("10", &["status:blocked"]);
3366 mock.blocked_tasks.push(parent.clone());
3367 mock.sub_issues.insert(
3368 "10".to_string(),
3369 vec![ExternalId("11".to_string()), ExternalId("12".to_string())],
3370 );
3371 mock.tasks_by_id
3372 .insert("11".to_string(), make_task("11", &["status:done"]));
3373 mock.tasks_by_id
3374 .insert("12".to_string(), make_task("12", &["status:done"]));
3375
3376 let status_updates = Arc::clone(&mock.status_updates);
3377 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3378 let task_manager = make_task_manager(Arc::clone(&backend));
3379 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3380
3381 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3382 .await
3383 .unwrap();
3384
3385 let updates = status_updates.lock().unwrap();
3386 assert_eq!(updates.len(), 1, "parent should be unblocked");
3387 assert_eq!(updates[0], ("10".to_string(), Status::New));
3388 }
3389
3390 #[tokio::test]
3391 async fn unblock_parents_skips_when_child_not_done() {
3392 let mut mock = MockBackend::new();
3393
3394 let parent = make_task("20", &["status:blocked"]);
3395 mock.blocked_tasks.push(parent.clone());
3396 mock.sub_issues.insert(
3397 "20".to_string(),
3398 vec![ExternalId("21".to_string()), ExternalId("22".to_string())],
3399 );
3400 mock.tasks_by_id
3402 .insert("21".to_string(), make_task("21", &["status:done"]));
3403 mock.tasks_by_id
3404 .insert("22".to_string(), make_task("22", &["status:in_progress"]));
3405
3406 let status_updates = Arc::clone(&mock.status_updates);
3407 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3408 let task_manager = make_task_manager(Arc::clone(&backend));
3409 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3410
3411 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3412 .await
3413 .unwrap();
3414
3415 let updates = status_updates.lock().unwrap();
3416 assert!(
3417 updates.is_empty(),
3418 "should not unblock when a child is still running"
3419 );
3420 }
3421
3422 #[tokio::test]
3423 async fn unblock_parents_skips_task_with_no_children() {
3424 let mut mock = MockBackend::new();
3425
3426 let parent = make_task("30", &["status:blocked"]);
3428 mock.blocked_tasks.push(parent.clone());
3429 let status_updates = Arc::clone(&mock.status_updates);
3432 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3433 let task_manager = make_task_manager(Arc::clone(&backend));
3434 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3435
3436 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3437 .await
3438 .unwrap();
3439
3440 let updates = status_updates.lock().unwrap();
3441 assert!(
3442 updates.is_empty(),
3443 "should not unblock tasks with no sub-issues"
3444 );
3445 }
3446
3447 #[tokio::test]
3448 async fn unblock_parents_skips_task_with_block_reason_in_store() {
3449 let mut mock = MockBackend::new();
3452
3453 let parent = make_task("60", &["status:blocked"]);
3455 mock.blocked_tasks.push(parent.clone());
3456 mock.sub_issues
3459 .insert("60".to_string(), vec![ExternalId("61".to_string())]);
3460 mock.tasks_by_id
3461 .insert("61".to_string(), make_task("61", &["status:done"]));
3462
3463 let status_updates = Arc::clone(&mock.status_updates);
3464 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3465 let task_manager = make_task_manager(Arc::clone(&backend));
3466
3467 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3469 let task_id = store
3470 .upsert_external(&crate::store::UpsertExternal {
3471 repo: "test/repo",
3472 ext_id: "60",
3473 title: "Task 60",
3474 body: "",
3475 author: "bot",
3476 url: "https://github.com/test/test/issues/60",
3477 labels: &[],
3478 origin: "github",
3479 })
3480 .await
3481 .unwrap();
3482 store
3483 .update_status(task_id, TaskStatus::Blocked)
3484 .await
3485 .unwrap();
3486 store
3487 .set_block_reason(task_id, Some("CI failure limit reached"))
3488 .await
3489 .unwrap();
3490
3491 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3492 .await
3493 .unwrap();
3494
3495 let updates = status_updates.lock().unwrap();
3496 assert!(
3497 updates.is_empty(),
3498 "task with block_reason should be skipped — no API call, no unblock"
3499 );
3500 }
3501
3502 #[tokio::test]
3503 async fn unblock_parents_unblocks_internal_parent_from_store_children() {
3504 let mock = MockBackend::new();
3505 let get_sub_issues_calls = Arc::clone(&mock.get_sub_issues_calls);
3506 let status_updates = Arc::clone(&mock.status_updates);
3507 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3508 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3509 let task_manager = Arc::new(TaskManager::with_store(
3510 Arc::clone(&backend),
3511 Arc::clone(&store),
3512 "test/repo".to_string(),
3513 ));
3514
3515 let parent_id = store
3516 .create_internal("test/repo", "Parent", "", "job", "daily", None)
3517 .await
3518 .unwrap();
3519 store
3520 .update_status(parent_id, TaskStatus::Blocked)
3521 .await
3522 .unwrap();
3523 let child_id = store
3524 .create(&NewTask {
3525 external_id: Some("91".to_string()),
3526 repo: "test/repo".to_string(),
3527 origin: "github".to_string(),
3528 title: "Child".to_string(),
3529 body: String::new(),
3530 source: String::new(),
3531 source_id: String::new(),
3532 author: String::new(),
3533 url: String::new(),
3534 labels: vec![],
3535 parent_id: Some(parent_id),
3536 })
3537 .await
3538 .unwrap();
3539 store
3540 .update_status(child_id, TaskStatus::Done)
3541 .await
3542 .unwrap();
3543
3544 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3545 .await
3546 .unwrap();
3547
3548 let parent = store.get(parent_id).await.unwrap();
3549 assert_eq!(parent.status, TaskStatus::New);
3550 assert!(
3551 status_updates.lock().unwrap().is_empty(),
3552 "internal parent should be updated in store without backend mirroring"
3553 );
3554 assert!(
3555 get_sub_issues_calls.lock().unwrap().is_empty(),
3556 "internal parent should not query backend sub-issues"
3557 );
3558 }
3559
3560 #[cfg(test)]
3564 async fn set_task_updated_at_past(store: &TaskStore, task_id: i64) {
3565 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
3566 .bind(task_id)
3567 .execute(store.pool())
3568 .await
3569 .unwrap();
3570 }
3571
3572 async fn set_in_review_entered_at_past(store: &TaskStore, task_id: i64) {
3574 sqlx::query(
3575 "UPDATE task_activity SET timestamp = '2020-01-01T00:00:00Z'
3576 WHERE task_id = ? AND to_status = 'in_review'",
3577 )
3578 .bind(task_id)
3579 .execute(store.pool())
3580 .await
3581 .unwrap();
3582 }
3583
3584 #[tokio::test]
3585 async fn recover_stuck_tasks_resets_external_in_review_to_needs_review() {
3586 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3587 let mock = MockBackend::new();
3588 let status_updates = Arc::clone(&mock.status_updates);
3589 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3590 let task_manager = Arc::new(TaskManager::with_store(
3591 Arc::clone(&backend),
3592 Arc::clone(&store),
3593 "owner/repo".to_string(),
3594 ));
3595 let tmux = Arc::new(TmuxManager::new());
3596 let config = EngineConfig {
3597 no_session_stuck_timeout: 600,
3598 stuck_timeout: 1800,
3599 ..EngineConfig::default()
3600 };
3601
3602 let id = store
3604 .upsert_external(&crate::store::UpsertExternal {
3605 repo: "owner/repo",
3606 ext_id: "99",
3607 title: "Stuck InReview",
3608 body: "",
3609 author: "",
3610 url: "",
3611 labels: &[],
3612 origin: "github",
3613 })
3614 .await
3615 .unwrap();
3616 store.update_status(id, TaskStatus::InReview).await.unwrap();
3617 set_in_review_entered_at_past(&store, id).await;
3618 set_task_updated_at_past(&store, id).await;
3619
3620 tick_recover_stuck_tasks(
3621 &backend,
3622 &tmux,
3623 "owner/repo",
3624 &task_manager,
3625 &config,
3626 &store,
3627 &HashMap::new(),
3628 &Arc::new(DashMap::new()),
3629 )
3630 .await
3631 .unwrap();
3632
3633 let updates = status_updates.lock().unwrap();
3634 assert_eq!(updates.len(), 1, "stuck InReview task should be recovered");
3635 assert_eq!(updates[0].0, "99");
3636 assert_eq!(updates[0].1, Status::NeedsReview);
3637 }
3638
3639 #[tokio::test]
3640 async fn recover_stuck_tasks_skips_recent_in_review() {
3641 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3642 let mock = MockBackend::new();
3643 let status_updates = Arc::clone(&mock.status_updates);
3644 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3645 let task_manager = Arc::new(TaskManager::with_store(
3646 Arc::clone(&backend),
3647 Arc::clone(&store),
3648 "owner/repo".to_string(),
3649 ));
3650 let tmux = Arc::new(TmuxManager::new());
3651 let config = EngineConfig {
3652 no_session_stuck_timeout: 600, stuck_timeout: 1800,
3654 ..EngineConfig::default()
3655 };
3656
3657 let id = store
3659 .upsert_external(&crate::store::UpsertExternal {
3660 repo: "owner/repo",
3661 ext_id: "100",
3662 title: "Recent InReview",
3663 body: "",
3664 author: "",
3665 url: "",
3666 labels: &[],
3667 origin: "github",
3668 })
3669 .await
3670 .unwrap();
3671 store.update_status(id, TaskStatus::InReview).await.unwrap();
3672 set_task_updated_at_past(&store, id).await;
3673
3674 tick_recover_stuck_tasks(
3675 &backend,
3676 &tmux,
3677 "owner/repo",
3678 &task_manager,
3679 &config,
3680 &store,
3681 &HashMap::new(),
3682 &Arc::new(DashMap::new()),
3683 )
3684 .await
3685 .unwrap();
3686
3687 let updates = status_updates.lock().unwrap();
3688 assert!(
3689 updates.is_empty(),
3690 "recent InReview task should not be recovered"
3691 );
3692 }
3693
3694 #[tokio::test]
3695 async fn recover_stuck_tasks_resets_internal_in_review_to_needs_review() {
3696 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3697 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3698 let task_manager = Arc::new(TaskManager::with_store(
3699 Arc::clone(&backend),
3700 Arc::clone(&store),
3701 "owner/repo".to_string(),
3702 ));
3703 let tmux = Arc::new(TmuxManager::new());
3704 let config = EngineConfig {
3705 no_session_stuck_timeout: 600,
3706 stuck_timeout: 1800,
3707 ..EngineConfig::default()
3708 };
3709
3710 let id = store
3712 .create_internal("owner/repo", "Internal InReview", "", "cron", "1", None)
3713 .await
3714 .unwrap();
3715 store.update_status(id, TaskStatus::InReview).await.unwrap();
3716 set_in_review_entered_at_past(&store, id).await;
3717 set_task_updated_at_past(&store, id).await;
3718
3719 tick_recover_stuck_tasks(
3720 &backend,
3721 &tmux,
3722 "owner/repo",
3723 &task_manager,
3724 &config,
3725 &store,
3726 &HashMap::new(),
3727 &Arc::new(DashMap::new()),
3728 )
3729 .await
3730 .unwrap();
3731
3732 let task = store.get(id).await.unwrap();
3733 assert_eq!(
3734 task.status,
3735 TaskStatus::NeedsReview,
3736 "stuck internal InReview task should be reset to NeedsReview"
3737 );
3738 }
3739
3740 #[tokio::test]
3741 async fn recover_stuck_tasks_ages_in_review_from_status_entry_not_updated_at() {
3742 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3743 let mock = MockBackend::new();
3744 let status_updates = Arc::clone(&mock.status_updates);
3745 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3746 let task_manager = Arc::new(TaskManager::with_store(
3747 Arc::clone(&backend),
3748 Arc::clone(&store),
3749 "owner/repo".to_string(),
3750 ));
3751 let tmux = Arc::new(TmuxManager::new());
3752 let config = EngineConfig {
3753 no_session_stuck_timeout: 600,
3754 stuck_timeout: 1800,
3755 ..EngineConfig::default()
3756 };
3757
3758 let id = store
3759 .upsert_external(&crate::store::UpsertExternal {
3760 repo: "owner/repo",
3761 ext_id: "102",
3762 title: "Fresh updated_at InReview",
3763 body: "",
3764 author: "",
3765 url: "",
3766 labels: &[],
3767 origin: "github",
3768 })
3769 .await
3770 .unwrap();
3771 store.update_status(id, TaskStatus::InReview).await.unwrap();
3772 set_in_review_entered_at_past(&store, id).await;
3773 store
3774 .set_fields(id, &[("pr_number", serde_json::json!(42))])
3775 .await
3776 .unwrap();
3777
3778 tick_recover_stuck_tasks(
3779 &backend,
3780 &tmux,
3781 "owner/repo",
3782 &task_manager,
3783 &config,
3784 &store,
3785 &HashMap::new(),
3786 &Arc::new(DashMap::new()),
3787 )
3788 .await
3789 .unwrap();
3790
3791 let updates = status_updates.lock().unwrap();
3792 assert_eq!(
3793 updates.len(),
3794 1,
3795 "in_review stuck recovery must age from the status entry even when updated_at is fresh"
3796 );
3797 assert_eq!(updates[0].0, "102");
3798 assert_eq!(updates[0].1, Status::NeedsReview);
3799 }
3800
3801 async fn orphaned_review_status_after_recovery(with_decision: bool) -> TaskStatus {
3804 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3805 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3806 let task_manager = Arc::new(TaskManager::with_store(
3807 Arc::clone(&backend),
3808 Arc::clone(&store),
3809 "owner/repo".to_string(),
3810 ));
3811 let tmux = Arc::new(TmuxManager::new());
3812
3813 let id = store
3814 .upsert_external(&crate::store::UpsertExternal {
3815 repo: "owner/repo",
3816 ext_id: "55",
3817 title: "Orphaned review",
3818 body: "",
3819 author: "",
3820 url: "",
3821 labels: &[],
3822 origin: "github",
3823 })
3824 .await
3825 .unwrap();
3826 store.update_status(id, TaskStatus::InReview).await.unwrap();
3827 set_in_review_entered_at_past(&store, id).await;
3829 if with_decision {
3830 store::store_log_activity(
3831 &Some(Arc::clone(&store)),
3832 "owner/repo",
3833 "55",
3834 "review_decision",
3835 None,
3836 None,
3837 None::<&str>,
3838 None::<&str>,
3839 Some(&serde_json::json!({"decision": "approve"})),
3840 )
3841 .await;
3842 }
3843
3844 tick_recover_stuck_tasks(
3845 &backend,
3846 &tmux,
3847 "owner/repo",
3848 &task_manager,
3849 &EngineConfig::default(),
3850 &store,
3851 &HashMap::new(),
3852 &Arc::new(DashMap::new()),
3853 )
3854 .await
3855 .unwrap();
3856
3857 store.get(id).await.unwrap().status
3858 }
3859
3860 #[tokio::test]
3861 async fn stale_in_review_recovery_resets_orphaned_task_without_session() {
3862 assert_eq!(
3863 orphaned_review_status_after_recovery(false).await,
3864 TaskStatus::NeedsReview
3865 );
3866 }
3867
3868 #[tokio::test]
3869 async fn stale_in_review_recovery_leaves_decided_task_waiting_on_merge() {
3870 assert_eq!(
3871 orphaned_review_status_after_recovery(true).await,
3872 TaskStatus::InReview
3873 );
3874 }
3875
3876 #[tokio::test]
3877 async fn recover_stuck_tasks_resets_review_agent_failures() {
3878 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3879 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3880 let task_manager = Arc::new(TaskManager::with_store(
3881 Arc::clone(&backend),
3882 Arc::clone(&store),
3883 "owner/repo".to_string(),
3884 ));
3885 let tmux = Arc::new(TmuxManager::new());
3886
3887 let id = store
3888 .upsert_external(&crate::store::UpsertExternal {
3889 repo: "owner/repo",
3890 ext_id: "104",
3891 title: "Orphaned review with failures",
3892 body: "",
3893 author: "",
3894 url: "",
3895 labels: &[],
3896 origin: "github",
3897 })
3898 .await
3899 .unwrap();
3900 store.update_status(id, TaskStatus::InReview).await.unwrap();
3901 store
3902 .set_fields(id, &[("review_agent_failures", serde_json::json!(2))])
3903 .await
3904 .unwrap();
3905 set_in_review_entered_at_past(&store, id).await;
3906
3907 tick_recover_stuck_tasks(
3908 &backend,
3909 &tmux,
3910 "owner/repo",
3911 &task_manager,
3912 &EngineConfig::default(),
3913 &store,
3914 &HashMap::new(),
3915 &Arc::new(DashMap::new()),
3916 )
3917 .await
3918 .unwrap();
3919
3920 let task = store.get(id).await.unwrap();
3921 assert_eq!(task.status, TaskStatus::NeedsReview);
3922 assert_eq!(
3923 task.review_agent_failures, 0,
3924 "a dead review session is an infrastructure event, not an agent failure"
3925 );
3926 }
3927
3928 #[tokio::test]
3929 async fn recover_stuck_tasks_reclaims_hung_in_review_session() {
3930 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3931 let mock = MockBackend::new();
3932 let status_updates = Arc::clone(&mock.status_updates);
3933 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3934 let task_manager = Arc::new(TaskManager::with_store(
3935 Arc::clone(&backend),
3936 Arc::clone(&store),
3937 "owner/repo".to_string(),
3938 ));
3939 let tmux = Arc::new(TmuxManager::new());
3940 let config = EngineConfig {
3941 no_session_stuck_timeout: 600,
3942 stuck_timeout: 1800,
3943 ..EngineConfig::default()
3944 };
3945
3946 let id = store
3947 .upsert_external(&crate::store::UpsertExternal {
3948 repo: "owner/repo",
3949 ext_id: "103",
3950 title: "Hung review session",
3951 body: "",
3952 author: "",
3953 url: "",
3954 labels: &[],
3955 origin: "github",
3956 })
3957 .await
3958 .unwrap();
3959 store.update_status(id, TaskStatus::InReview).await.unwrap();
3960 set_in_review_entered_at_past(&store, id).await;
3961 store
3962 .set_fields(id, &[("pr_number", serde_json::json!(43))])
3963 .await
3964 .unwrap();
3965
3966 let session_name = tmux.session_name("owner/repo", "103-review");
3967 let mut session_map = HashMap::new();
3968 session_map.insert(session_name, true);
3969
3970 tick_recover_stuck_tasks(
3971 &backend,
3972 &tmux,
3973 "owner/repo",
3974 &task_manager,
3975 &config,
3976 &store,
3977 &session_map,
3978 &Arc::new(DashMap::new()),
3979 )
3980 .await
3981 .unwrap();
3982
3983 let updates = status_updates.lock().unwrap();
3984 assert_eq!(
3985 updates.len(),
3986 1,
3987 "a live hung review session must be reclaimed once the in_review entry ages past stuck_timeout"
3988 );
3989 assert_eq!(updates[0].0, "103");
3990 assert_eq!(updates[0].1, Status::NeedsReview);
3991 }
3992
3993 #[tokio::test]
3994 async fn untracked_repo_recovery_resets_stale_in_progress_to_new() {
3995 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3996 let tmux = Arc::new(TmuxManager::new());
3997 let config = EngineConfig {
3998 no_session_stuck_timeout: 600,
3999 stuck_timeout: 1800,
4000 ..EngineConfig::default()
4001 };
4002
4003 let id = store
4005 .upsert_external(&crate::store::UpsertExternal {
4006 repo: "owner/other",
4007 ext_id: "777",
4008 title: "Cross-repo stuck task",
4009 body: "",
4010 author: "",
4011 url: "",
4012 labels: &[],
4013 origin: "github",
4014 })
4015 .await
4016 .unwrap();
4017 store
4018 .update_status(id, TaskStatus::InProgress)
4019 .await
4020 .unwrap();
4021 set_task_updated_at_past(&store, id).await;
4022
4023 let active = HashSet::from(["owner/repo".to_string()]);
4024 recover_untracked_repo_stuck_tasks(
4025 &tmux,
4026 &config,
4027 &store,
4028 &HashMap::new(),
4029 &Arc::new(DashMap::new()),
4030 &active,
4031 )
4032 .await;
4033
4034 let task = store.get(id).await.unwrap();
4035 assert_eq!(
4036 task.status,
4037 TaskStatus::New,
4038 "cross-repo stale in_progress task should be reset to New"
4039 );
4040 assert_eq!(task.route_attempts, 0, "route attempts should be cleared");
4041 assert!(task.agent.is_none(), "agent should be cleared");
4042 assert!(task.model.is_none(), "model should be cleared");
4043 }
4044
4045 #[tokio::test]
4046 async fn untracked_repo_recovery_leaves_active_repos_to_their_own_pass() {
4047 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4048 let tmux = Arc::new(TmuxManager::new());
4049 let config = EngineConfig {
4050 no_session_stuck_timeout: 600,
4051 stuck_timeout: 1800,
4052 ..EngineConfig::default()
4053 };
4054 let id = store
4055 .upsert_external(&crate::store::UpsertExternal {
4056 repo: "owner/other",
4057 ext_id: "778",
4058 title: "Stuck task in an active repo",
4059 body: "",
4060 author: "",
4061 url: "",
4062 labels: &[],
4063 origin: "github",
4064 })
4065 .await
4066 .unwrap();
4067 store
4068 .update_status(id, TaskStatus::InProgress)
4069 .await
4070 .unwrap();
4071 set_task_updated_at_past(&store, id).await;
4072
4073 let active = HashSet::from(["owner/repo".to_string(), "owner/other".to_string()]);
4075 recover_untracked_repo_stuck_tasks(
4076 &tmux,
4077 &config,
4078 &store,
4079 &HashMap::new(),
4080 &Arc::new(DashMap::new()),
4081 &active,
4082 )
4083 .await;
4084
4085 assert_eq!(store.get(id).await.unwrap().status, TaskStatus::InProgress);
4086 }
4087
4088 #[tokio::test]
4089 async fn recover_stuck_tasks_finalizes_open_task_runs_rows() {
4090 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4091 let mock = MockBackend::new();
4092 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4093 let task_manager = Arc::new(TaskManager::with_store(
4094 Arc::clone(&backend),
4095 Arc::clone(&store),
4096 "owner/repo".to_string(),
4097 ));
4098 let tmux = Arc::new(TmuxManager::new());
4099 let config = EngineConfig {
4100 no_session_stuck_timeout: 600,
4101 stuck_timeout: 1800,
4102 ..EngineConfig::default()
4103 };
4104
4105 let id = store
4106 .create_internal("owner/repo", "Stuck with open run", "", "cron", "1", None)
4107 .await
4108 .unwrap();
4109 store
4110 .set_fields(
4111 id,
4112 &[
4113 ("agent", serde_json::json!("claude")),
4114 ("model", serde_json::json!("sonnet")),
4115 ("route_attempts", serde_json::json!(1)),
4116 ],
4117 )
4118 .await
4119 .unwrap();
4120 store
4121 .update_status(id, TaskStatus::InProgress)
4122 .await
4123 .unwrap();
4124 set_task_updated_at_past(&store, id).await;
4125
4126 store
4128 .start_run(&crate::store::StartRun {
4129 task_id: id,
4130 attempt: 1,
4131 run_type: "agent",
4132 agent: "claude",
4133 model: "sonnet",
4134 command: "claude --model sonnet",
4135 prompt: "fix it",
4136 })
4137 .await
4138 .unwrap();
4139
4140 tick_recover_stuck_tasks(
4141 &backend,
4142 &tmux,
4143 "owner/repo",
4144 &task_manager,
4145 &config,
4146 &store,
4147 &HashMap::new(),
4148 &Arc::new(DashMap::new()),
4149 )
4150 .await
4151 .unwrap();
4152
4153 let task = store.get(id).await.unwrap();
4154 assert_eq!(task.status, TaskStatus::New);
4155
4156 let runs = store.get_runs(id).await.unwrap();
4157 assert_eq!(runs.len(), 1);
4158 assert_eq!(runs[0].outcome, "aborted");
4159 assert!(runs[0].completed_at.is_some());
4160 assert_eq!(
4161 runs[0].error,
4162 "stuck-task recovery: internal no session found"
4163 );
4164 }
4165
4166 #[tokio::test]
4183 async fn recover_stuck_tasks_skips_reclaim_while_dispatch_guard_held() {
4184 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4185 let mock = MockBackend::new();
4186 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4187 let task_manager = Arc::new(TaskManager::with_store(
4188 Arc::clone(&backend),
4189 Arc::clone(&store),
4190 "owner/repo".to_string(),
4191 ));
4192 let tmux = Arc::new(TmuxManager::new());
4193 let config = EngineConfig {
4194 no_session_stuck_timeout: 600,
4195 stuck_timeout: 1800,
4196 ..EngineConfig::default()
4197 };
4198
4199 let id = store
4200 .create_internal(
4201 "owner/repo",
4202 "Long-running daily job",
4203 "",
4204 "cron",
4205 "1",
4206 None,
4207 )
4208 .await
4209 .unwrap();
4210 store
4211 .set_fields(
4212 id,
4213 &[
4214 ("agent", serde_json::json!("claude")),
4215 ("model", serde_json::json!("sonnet")),
4216 ("route_attempts", serde_json::json!(1)),
4217 ],
4218 )
4219 .await
4220 .unwrap();
4221 store
4222 .update_status(id, TaskStatus::InProgress)
4223 .await
4224 .unwrap();
4225 set_task_updated_at_past(&store, id).await;
4228
4229 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4232 dispatching.insert(
4233 format!("owner/repo/internal:{id}"),
4234 format!("internal:{id}"),
4235 );
4236
4237 tick_recover_stuck_tasks(
4238 &backend,
4239 &tmux,
4240 "owner/repo",
4241 &task_manager,
4242 &config,
4243 &store,
4244 &HashMap::new(),
4245 &dispatching,
4246 )
4247 .await
4248 .unwrap();
4249
4250 let task = store.get(id).await.unwrap();
4251 assert_eq!(
4252 task.status,
4253 TaskStatus::InProgress,
4254 "task should not be reclaimed while its dispatch guard is still held"
4255 );
4256
4257 let runs = store.get_runs(id).await.unwrap();
4259 assert!(runs.is_empty());
4260
4261 dispatching.clear();
4264 tick_recover_stuck_tasks(
4265 &backend,
4266 &tmux,
4267 "owner/repo",
4268 &task_manager,
4269 &config,
4270 &store,
4271 &HashMap::new(),
4272 &dispatching,
4273 )
4274 .await
4275 .unwrap();
4276 let task = store.get(id).await.unwrap();
4277 assert_eq!(
4278 task.status,
4279 TaskStatus::New,
4280 "task should still be reclaimed once the dispatch guard is released"
4281 );
4282 }
4283
4284 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
4300 async fn recover_stuck_tasks_never_reclaims_while_guard_held_under_real_concurrency() {
4301 const HAMMER_ITERATIONS: u32 = 300;
4302
4303 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4304 let mock = MockBackend::new();
4305 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4306 let task_manager = Arc::new(TaskManager::with_store(
4307 Arc::clone(&backend),
4308 Arc::clone(&store),
4309 "owner/repo".to_string(),
4310 ));
4311 let tmux = Arc::new(TmuxManager::new());
4312 let config = EngineConfig {
4313 no_session_stuck_timeout: 600,
4314 stuck_timeout: 1800,
4315 ..EngineConfig::default()
4316 };
4317
4318 let id = store
4319 .create_internal(
4320 "owner/repo",
4321 "Long-running daily job",
4322 "",
4323 "cron",
4324 "1",
4325 None,
4326 )
4327 .await
4328 .unwrap();
4329 store
4330 .set_fields(
4331 id,
4332 &[
4333 ("agent", serde_json::json!("claude")),
4334 ("model", serde_json::json!("sonnet")),
4335 ("route_attempts", serde_json::json!(1)),
4336 ],
4337 )
4338 .await
4339 .unwrap();
4340 store
4341 .update_status(id, TaskStatus::InProgress)
4342 .await
4343 .unwrap();
4344 set_task_updated_at_past(&store, id).await;
4345
4346 let dispatch_key = format!("owner/repo/internal:{id}");
4347 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4348
4349 let claim_task_id = format!("internal:{id}");
4356 {
4357 match dispatching.entry(dispatch_key.clone()) {
4358 Entry::Vacant(slot) => {
4359 slot.insert(claim_task_id);
4360 }
4361 Entry::Occupied(_) => panic!("dispatch key already claimed"),
4362 }
4363 }
4364 let guard = DispatchGuard::new(Arc::clone(&dispatching), dispatch_key.clone());
4365
4366 let (release_tx, release_rx) = oneshot::channel::<()>();
4377 let holder = tokio::spawn(async move {
4378 let _ = release_rx.await;
4379 drop(guard);
4380 });
4381
4382 for i in 0..HAMMER_ITERATIONS {
4390 tick_recover_stuck_tasks(
4391 &backend,
4392 &tmux,
4393 "owner/repo",
4394 &task_manager,
4395 &config,
4396 &store,
4397 &HashMap::new(),
4398 &dispatching,
4399 )
4400 .await
4401 .unwrap();
4402
4403 let task = store.get(id).await.unwrap();
4404 assert_eq!(
4405 task.status,
4406 TaskStatus::InProgress,
4407 "task must never be reclaimed while the dispatch guard is deterministically \
4408 still held, even under real cross-thread concurrency (dispatch_key={dispatch_key}, \
4409 iteration={i}, contains_key_now={}, map_len_now={})",
4410 dispatching.contains_key(&dispatch_key),
4411 dispatching.len(),
4412 );
4413 task::yield_now().await;
4414 }
4415
4416 let _ = release_tx.send(());
4418 holder.await.unwrap();
4419
4420 tick_recover_stuck_tasks(
4422 &backend,
4423 &tmux,
4424 "owner/repo",
4425 &task_manager,
4426 &config,
4427 &store,
4428 &HashMap::new(),
4429 &dispatching,
4430 )
4431 .await
4432 .unwrap();
4433 let task = store.get(id).await.unwrap();
4434 assert_eq!(
4435 task.status,
4436 TaskStatus::New,
4437 "task should be reclaimed once the dispatch guard is released"
4438 );
4439 }
4440
4441 #[tokio::test]
4442 async fn recover_stuck_tasks_skips_external_in_review_waiting_for_human_review() {
4443 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4444 let mock = MockBackend::new();
4445 let status_updates = Arc::clone(&mock.status_updates);
4446 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4447 let task_manager = Arc::new(TaskManager::with_store(
4448 Arc::clone(&backend),
4449 Arc::clone(&store),
4450 "owner/repo".to_string(),
4451 ));
4452 let tmux = Arc::new(TmuxManager::new());
4453 let config = EngineConfig {
4454 no_session_stuck_timeout: 600,
4455 stuck_timeout: 1800,
4456 ..EngineConfig::default()
4457 };
4458
4459 let id = store
4460 .upsert_external(&crate::store::UpsertExternal {
4461 repo: "owner/repo",
4462 ext_id: "101",
4463 title: "Waiting For Human Review",
4464 body: "",
4465 author: "",
4466 url: "",
4467 labels: &[],
4468 origin: "github",
4469 })
4470 .await
4471 .unwrap();
4472 store.update_status(id, TaskStatus::InReview).await.unwrap();
4473 set_task_updated_at_past(&store, id).await;
4474
4475 tick_recover_stuck_tasks(
4476 &backend,
4477 &tmux,
4478 "owner/repo",
4479 &task_manager,
4480 &config,
4481 &store,
4482 &HashMap::new(),
4483 &Arc::new(DashMap::new()),
4484 )
4485 .await
4486 .unwrap();
4487
4488 let updates = status_updates.lock().unwrap();
4489 assert!(
4490 updates.is_empty(),
4491 "InReview task waiting for human review should not be reset"
4492 );
4493 }
4494
4495 #[tokio::test]
4496 async fn unblock_parents_no_blocked_tasks() {
4497 let mock = MockBackend::new(); let status_updates = Arc::clone(&mock.status_updates);
4499 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4500 let task_manager = make_task_manager(Arc::clone(&backend));
4501 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4502
4503 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
4504 .await
4505 .unwrap();
4506
4507 assert!(status_updates.lock().unwrap().is_empty());
4508 }
4509
4510 #[tokio::test]
4511 async fn unblock_parents_handles_multiple_blocked_tasks() {
4512 let mut mock = MockBackend::new();
4513
4514 let t40 = make_task("40", &["status:blocked"]);
4516 mock.blocked_tasks.push(t40);
4517 mock.sub_issues
4518 .insert("40".to_string(), vec![ExternalId("41".to_string())]);
4519 mock.tasks_by_id
4520 .insert("41".to_string(), make_task("41", &["status:done"]));
4521
4522 let t50 = make_task("50", &["status:blocked"]);
4524 mock.blocked_tasks.push(t50);
4525 mock.sub_issues
4526 .insert("50".to_string(), vec![ExternalId("51".to_string())]);
4527 mock.tasks_by_id
4528 .insert("51".to_string(), make_task("51", &["status:in_progress"]));
4529
4530 let status_updates = Arc::clone(&mock.status_updates);
4531 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4532 let task_manager = make_task_manager(Arc::clone(&backend));
4533 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4534
4535 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
4536 .await
4537 .unwrap();
4538
4539 let updates = status_updates.lock().unwrap();
4540 assert_eq!(updates.len(), 1, "only one parent should be unblocked");
4541 assert_eq!(updates[0].0, "40", "task 40 should be unblocked");
4542 assert_eq!(updates[0].1, Status::New);
4543 }
4544
4545 #[tokio::test]
4548 async fn tick_all_is_a_no_op_without_projects() {
4549 let transport = Arc::new(Transport::new());
4550 let capture = Arc::new(CaptureService::new(Arc::clone(&transport)));
4551 let tmux = Arc::new(TmuxManager::new());
4552 let semaphore = Arc::new(Semaphore::new(1));
4553 let router = Arc::new(RwLock::new(cooled_review_router_for_test()));
4554 let (weight_tx, mut weight_rx) = mpsc::channel(8);
4555 let dispatching = Arc::new(DashMap::new());
4556 let auto_merge_in_flight = Arc::new(DashSet::new());
4557
4558 tick_all(
4559 &[],
4560 &tmux,
4561 &capture,
4562 &semaphore,
4563 &EngineConfig::default(),
4564 &router,
4565 &weight_tx,
4566 &dispatching,
4567 Some(&transport),
4568 &HashSet::new(),
4569 &auto_merge_in_flight,
4570 )
4571 .await;
4572
4573 assert!(dispatching.is_empty(), "no project, so no dispatch claimed");
4574 assert!(
4575 weight_rx.try_recv().is_err(),
4576 "no project, so no weight signal"
4577 );
4578 }
4579
4580 #[serial_test::serial(cooldown_state)]
4585 #[tokio::test]
4586 async fn tick_all_runs_the_global_needs_review_sweep_once_for_two_projects() {
4587 cooldown::reset_global_state().await;
4588
4589 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4590 let id = store
4591 .upsert_external(&UpsertExternal {
4592 repo: "owner/one",
4593 ext_id: "7",
4594 title: "Stale needs_review task",
4595 body: "",
4596 author: "user",
4597 url: "https://github.com/owner/one/issues/7",
4598 labels: &[],
4599 origin: "github",
4600 })
4601 .await
4602 .unwrap();
4603 store
4604 .update_status(id, TaskStatus::NeedsReview)
4605 .await
4606 .unwrap();
4607 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4608 .bind(id)
4609 .execute(store.pool())
4610 .await
4611 .unwrap();
4612
4613 let engines: Vec<ProjectEngine> = ["owner/one", "owner/two"]
4614 .iter()
4615 .map(|repo| {
4616 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4617 ProjectEngine {
4618 repo: repo.to_string(),
4619 project_dir: PathBuf::from("/nonexistent"),
4620 backend: Arc::clone(&backend),
4621 task_manager: Arc::new(TaskManager::with_store(
4622 backend,
4623 Arc::clone(&store),
4624 repo.to_string(),
4625 )),
4626 runner: Arc::new(TaskRunner::new(repo.to_string())),
4627 store: Arc::clone(&store),
4628 }
4629 })
4630 .collect();
4631
4632 let mut config = RouterConfig::default();
4634 config
4635 .model_map
4636 .entry("review".to_string())
4637 .or_default()
4638 .insert("claude".to_string(), vec!["sonnet".to_string()]);
4639 let router = Arc::new(RwLock::new(Router::new_for_test(
4640 config,
4641 vec!["claude".to_string()],
4642 )));
4643
4644 let transport = Arc::new(Transport::new());
4645 let capture = Arc::new(CaptureService::new(Arc::clone(&transport)));
4646 let (weight_tx, _weight_rx) = mpsc::channel(8);
4647
4648 tick_all(
4649 &engines,
4650 &Arc::new(TmuxManager::new()),
4651 &capture,
4652 &Arc::new(Semaphore::new(1)),
4653 &EngineConfig::default(),
4654 &router,
4655 &weight_tx,
4656 &Arc::new(DashMap::new()),
4657 Some(&transport),
4658 &engines.iter().map(|e| e.repo.clone()).collect(),
4659 &Arc::new(DashSet::new()),
4660 )
4661 .await;
4662
4663 assert_eq!(
4664 store.get(id).await.unwrap().needs_review_refires,
4665 1,
4666 "two projects must share one pass of the global NeedsReview sweep"
4667 );
4668 }
4669
4670 #[tokio::test]
4673 async fn tick_all_does_not_kill_sessions_it_did_not_create() {
4674 let tmux = Arc::new(TmuxManager::new());
4675 let Ok(session_name) = tmux
4676 .create_session("other/foreign", "3889", "/tmp", "sleep 30", &[])
4677 .await
4678 else {
4679 eprintln!("Skipping test: tmux not available or failed to create test session");
4680 return;
4681 };
4682 let _guard = SessionGuard(session_name.clone());
4683
4684 time::sleep(Duration::from_millis(1100)).await;
4687
4688 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4689 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4690 let engines = vec![ProjectEngine {
4691 repo: "owner/one".to_string(),
4692 project_dir: PathBuf::from("/nonexistent"),
4693 backend: Arc::clone(&backend),
4694 task_manager: Arc::new(TaskManager::with_store(
4695 backend,
4696 Arc::clone(&store),
4697 "owner/one".to_string(),
4698 )),
4699 runner: Arc::new(TaskRunner::new("owner/one".to_string())),
4700 store,
4701 }];
4702
4703 let transport = Arc::new(Transport::new());
4704 let capture = Arc::new(CaptureService::new(Arc::clone(&transport)));
4705 let (weight_tx, _weight_rx) = mpsc::channel(8);
4706 let router = Arc::new(RwLock::new(cooled_review_router_for_test()));
4707
4708 tick_all(
4709 &engines,
4710 &tmux,
4711 &capture,
4712 &Arc::new(Semaphore::new(1)),
4713 &EngineConfig::default(),
4714 &router,
4715 &weight_tx,
4716 &Arc::new(DashMap::new()),
4717 Some(&transport),
4718 &engines.iter().map(|e| e.repo.clone()).collect(),
4719 &Arc::new(DashSet::new()),
4720 )
4721 .await;
4722
4723 assert!(
4724 tmux.session_exists(&session_name).await,
4725 "tick_all must not kill a tmux session it did not start"
4726 );
4727 }
4728
4729 #[tokio::test]
4730 async fn detect_silent_agents_skips_backend_for_internal_tasks() {
4731 let transport = Arc::new(Transport::new());
4732 let capture = Arc::new(CaptureService::new(transport));
4733 let mock = Arc::new(MockBackend::new());
4734 let backend: Arc<dyn ExternalBackend> = Arc::<MockBackend>::clone(&mock);
4735 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4736 let task_manager = Arc::new(TaskManager::with_store(
4737 Arc::clone(&backend),
4738 Arc::clone(&store),
4739 "owner/repo".to_string(),
4740 ));
4741 let tmux = Arc::new(TmuxManager::new());
4742 let config = EngineConfig {
4743 silence_grace_period: 0,
4744 ..EngineConfig::default()
4745 };
4746
4747 let internal_id = store
4748 .create_internal("owner/repo", "Silent internal", "", "cron", "1", None)
4749 .await
4750 .unwrap();
4751 store
4752 .update_status(internal_id, TaskStatus::InProgress)
4753 .await
4754 .unwrap();
4755
4756 let task_id = format!("internal:{internal_id}");
4757 capture
4758 .register_session("owner/repo", &task_id, "orch-test-internal")
4759 .await;
4760 time::sleep(Duration::from_millis(1100)).await;
4761
4762 tick_detect_silent_agents(
4763 &tmux,
4764 "owner/repo",
4765 &capture,
4766 &backend,
4767 &task_manager,
4768 &config,
4769 &store,
4770 )
4771 .await
4772 .unwrap();
4773
4774 assert!(
4775 mock.removed_labels.lock().unwrap().is_empty(),
4776 "internal tasks should skip backend label removal"
4777 );
4778 assert!(
4779 mock.posted_comments.lock().unwrap().is_empty(),
4780 "internal tasks should skip backend comments"
4781 );
4782
4783 let task = store.get(internal_id).await.unwrap();
4784 assert!(
4788 matches!(task.status, TaskStatus::Routed | TaskStatus::New),
4789 "internal task should be routed to fallback or reset to new, got {:?}",
4790 task.status
4791 );
4792 }
4793
4794 #[tokio::test]
4795 async fn detect_silent_agents_finalizes_open_task_runs_rows() {
4796 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4797 let mock = MockBackend::new();
4798 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4799 let task_manager = Arc::new(TaskManager::with_store(
4800 Arc::clone(&backend),
4801 Arc::clone(&store),
4802 "owner/repo".to_string(),
4803 ));
4804 let transport = Arc::new(Transport::new());
4805 let capture = Arc::new(CaptureService::new(transport));
4806 let tmux = Arc::new(TmuxManager::new());
4807 let config = EngineConfig {
4808 silence_grace_period: 60,
4809 silence_cooldown: 300,
4810 ..EngineConfig::default()
4811 };
4812
4813 let internal_id = store
4814 .create_internal("owner/repo", "Silent with open run", "", "cron", "1", None)
4815 .await
4816 .unwrap();
4817 store
4818 .set_fields(
4819 internal_id,
4820 &[
4821 ("agent", serde_json::json!("claude")),
4822 ("model", serde_json::json!("sonnet")),
4823 ],
4824 )
4825 .await
4826 .unwrap();
4827 store
4828 .update_status(internal_id, TaskStatus::InProgress)
4829 .await
4830 .unwrap();
4831
4832 let task_id = format!("internal:{internal_id}");
4833 capture
4834 .register_session("owner/repo", &task_id, "orch-test-silent-run")
4835 .await;
4836 capture
4837 .set_buffer_state_for_test(
4838 "owner/repo",
4839 &task_id,
4840 false,
4841 false,
4842 chrono::Utc::now() - chrono::Duration::seconds(120),
4843 )
4844 .await;
4845
4846 store
4848 .start_run(&crate::store::StartRun {
4849 task_id: internal_id,
4850 attempt: 1,
4851 run_type: "agent",
4852 agent: "claude",
4853 model: "sonnet",
4854 command: "claude --model sonnet",
4855 prompt: "fix it",
4856 })
4857 .await
4858 .unwrap();
4859
4860 tick_detect_silent_agents(
4861 &tmux,
4862 "owner/repo",
4863 &capture,
4864 &backend,
4865 &task_manager,
4866 &config,
4867 &store,
4868 )
4869 .await
4870 .unwrap();
4871
4872 let runs = store.get_runs(internal_id).await.unwrap();
4873 assert_eq!(runs.len(), 1);
4874 assert_eq!(runs[0].outcome, "aborted");
4875 assert!(runs[0].completed_at.is_some());
4876 assert_eq!(runs[0].error, "silence detection: session killed");
4877 }
4878
4879 #[tokio::test]
4890 async fn phase1_session_completion_touch_prevents_phase2_reclaim() {
4891 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4892 let mock = MockBackend::new();
4893 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4894 let task_manager = Arc::new(TaskManager::with_store(
4895 Arc::clone(&backend),
4896 Arc::clone(&store),
4897 "owner/repo".to_string(),
4898 ));
4899 let tmux = Arc::new(TmuxManager::new());
4900 let config = EngineConfig {
4901 no_session_stuck_timeout: 600,
4902 stuck_timeout: 1800,
4903 ..EngineConfig::default()
4904 };
4905
4906 let id = store
4910 .create_internal("owner/repo", "Completed task", "", "cron", "1", None)
4911 .await
4912 .unwrap();
4913 store
4914 .update_status(id, TaskStatus::InProgress)
4915 .await
4916 .unwrap();
4917 set_task_updated_at_past(&store, id).await;
4918
4919 let task_id = format!("internal:{id}");
4922 store_touch_updated_at(&Some(Arc::clone(&store)), "owner/repo", &task_id).await;
4923
4924 tick_recover_stuck_tasks(
4926 &backend,
4927 &tmux,
4928 "owner/repo",
4929 &task_manager,
4930 &config,
4931 &store,
4932 &HashMap::new(),
4933 &Arc::new(DashMap::new()),
4934 )
4935 .await
4936 .unwrap();
4937
4938 let task = store.get(id).await.unwrap();
4939 assert_eq!(
4940 task.status,
4941 TaskStatus::InProgress,
4942 "Phase 2 must not reclaim a task whose updated_at was just touched by Phase 1"
4943 );
4944 }
4945
4946 #[tokio::test]
4947 async fn stuck_task_timing_treats_dead_session_as_missing() {
4948 let tmux = Arc::new(TmuxManager::new());
4949 let repo = "owner/repo";
4950 let task_id = "internal:33498";
4951 let session_name = tmux.session_name(repo, task_id);
4952
4953 let create_result = tmux
4954 .command()
4955 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
4956 .output()
4957 .await;
4958
4959 match create_result {
4960 Ok(o) if o.status.success() => {}
4961 _ => {
4962 eprintln!("Skipping test: tmux not available or failed to create test session");
4963 return;
4964 }
4965 }
4966
4967 let _guard = SessionGuard(session_name.clone());
4968
4969 let set_option_result = tmux
4970 .command()
4971 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
4972 .output()
4973 .await;
4974 if !matches!(set_option_result, Ok(ref o) if o.status.success()) {
4975 eprintln!("Skipping test: unable to set tmux remain-on-exit option");
4976 return;
4977 }
4978
4979 let send_exit_result = tmux
4980 .command()
4981 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
4982 .output()
4983 .await;
4984 if !matches!(send_exit_result, Ok(ref o) if o.status.success()) {
4985 eprintln!("Skipping test: unable to exit tmux pane");
4986 return;
4987 }
4988
4989 for _ in 0..20 {
4991 time::sleep(Duration::from_millis(100)).await;
4992 if !tmux.session_is_running(&session_name).await {
4993 break;
4994 }
4995 }
4996
4997 let mut session_map = tmux.batch_session_active().await;
5002 for _ in 0..20 {
5003 if !session_map.get(&session_name).copied().unwrap_or(false) {
5004 break;
5005 }
5006 time::sleep(Duration::from_millis(100)).await;
5007 session_map = tmux.batch_session_active().await;
5008 }
5009 if session_map.get(&session_name).copied().unwrap_or(false) {
5010 eprintln!(
5011 "Skipping test: session still alive in batch_session_active after retries (CI tmux lag)"
5012 );
5013 return;
5014 }
5015
5016 let config = EngineConfig {
5017 no_session_stuck_timeout: 600,
5018 stuck_timeout: 1800,
5019 ..EngineConfig::default()
5020 };
5021 let updated_at = (chrono::Utc::now()
5022 - chrono::Duration::seconds(
5023 i64::try_from(config.no_session_stuck_timeout).expect("timeout fits in i64") + 5,
5024 ))
5025 .to_rfc3339();
5026
5027 let timing = stuck_task_timing_from_map(
5028 &tmux,
5029 repo,
5030 task_id,
5031 &updated_at,
5032 &config,
5033 "parse failure",
5034 &session_map,
5035 )
5036 .expect("dead session should be treated as missing");
5037
5038 assert!(!timing.has_session);
5039 assert_eq!(timing.threshold, config.no_session_stuck_timeout);
5040 }
5041
5042 #[tokio::test]
5043 #[serial_test::serial(suspend_state)]
5044 async fn stuck_task_timing_discounts_host_suspend_gap() {
5045 super::super::suspend::clear_for_test();
5046
5047 let tmux = Arc::new(TmuxManager::new());
5048 let repo = "owner/repo";
5049 let task_id = "internal:99001";
5050 let session_map = HashMap::new();
5051
5052 let config = EngineConfig {
5053 no_session_stuck_timeout: 1800,
5054 stuck_timeout: 1800,
5055 ..EngineConfig::default()
5056 };
5057
5058 let updated_at = (chrono::Utc::now() - chrono::Duration::minutes(33)).to_rfc3339();
5062 let suspend_gap_detected_at = chrono::Utc::now() - chrono::Duration::minutes(1);
5063 super::super::suspend::inject_gap_for_test(
5064 suspend_gap_detected_at,
5065 chrono::Duration::minutes(30),
5066 );
5067
5068 let timing = stuck_task_timing_from_map(
5069 &tmux,
5070 repo,
5071 task_id,
5072 &updated_at,
5073 &config,
5074 "parse failure",
5075 &session_map,
5076 );
5077
5078 assert!(
5079 timing.is_none(),
5080 "task age should be discounted below the stuck threshold once suspend time is subtracted, got {timing:?}"
5081 );
5082
5083 super::super::suspend::clear_for_test();
5084 }
5085
5086 #[tokio::test]
5087 async fn recover_stuck_tasks_reclaims_routed_task_blocked_by_stale_session() {
5088 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5089 let mock = MockBackend::new();
5090 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5091 let task_manager = Arc::new(TaskManager::with_store(
5092 Arc::clone(&backend),
5093 Arc::clone(&store),
5094 "owner/repo".to_string(),
5095 ));
5096 let tmux = Arc::new(TmuxManager::new());
5097 let config = EngineConfig {
5098 no_session_stuck_timeout: 600,
5099 stuck_timeout: 1,
5100 ..EngineConfig::default()
5101 };
5102
5103 let id = store
5104 .create_internal("owner/repo", "Blocked routed task", "", "cron", "1", None)
5105 .await
5106 .unwrap();
5107 store.update_status(id, TaskStatus::Routed).await.unwrap();
5108 set_task_updated_at_past(&store, id).await;
5109
5110 let task_id = format!("internal:{id}");
5111 let session_name = tmux.session_name("owner/repo", &task_id);
5112 let create_result = tmux
5113 .command()
5114 .args([
5115 "new-session",
5116 "-d",
5117 "-s",
5118 &session_name,
5119 "-c",
5120 "/tmp",
5121 "sleep 60",
5122 ])
5123 .output()
5124 .await;
5125
5126 match create_result {
5127 Ok(o) if o.status.success() => {}
5128 _ => {
5129 eprintln!("Skipping test: tmux not available or failed to create test session");
5130 return;
5131 }
5132 }
5133
5134 let session_map = tmux.batch_session_active().await;
5135 assert_eq!(session_map.get(&session_name).copied(), Some(true));
5136
5137 tick_recover_stuck_tasks(
5138 &backend,
5139 &tmux,
5140 "owner/repo",
5141 &task_manager,
5142 &config,
5143 &store,
5144 &session_map,
5145 &Arc::new(DashMap::new()),
5146 )
5147 .await
5148 .unwrap();
5149
5150 let task = store.get(id).await.unwrap();
5151 assert_eq!(
5152 task.status,
5153 TaskStatus::New,
5154 "routed task blocked by a stale session should be reset for re-routing"
5155 );
5156 assert!(
5157 !tmux.session_exists(&session_name).await,
5158 "recovery should kill the stale tmux session so dispatch can proceed"
5159 );
5160 }
5161
5162 async fn live_complex_run(
5165 store: &TaskStore,
5166 tmux: &TmuxManager,
5167 minutes: i64,
5168 updated_minutes_ago: i64,
5169 ) -> Option<(i64, String)> {
5170 let id = store
5171 .create_internal("owner/repo", "Long complex run", "", "cron", "1", None)
5172 .await
5173 .unwrap();
5174 store
5175 .set_fields(id, &[("complexity", serde_json::json!("complex"))])
5176 .await
5177 .unwrap();
5178 store
5179 .update_status(id, TaskStatus::InProgress)
5180 .await
5181 .unwrap();
5182 let ago = |m: i64| {
5183 (chrono::Utc::now() - chrono::Duration::minutes(m))
5184 .format("%Y-%m-%dT%H:%M:%SZ")
5185 .to_string()
5186 };
5187 let entered = ago(minutes);
5188 sqlx::query(
5189 "UPDATE task_activity SET timestamp = ? WHERE task_id = ? AND to_status = 'in_progress'",
5190 )
5191 .bind(&entered)
5192 .bind(id)
5193 .execute(store.pool())
5194 .await
5195 .unwrap();
5196 sqlx::query("UPDATE tasks SET updated_at = ? WHERE id = ?")
5197 .bind(ago(updated_minutes_ago))
5198 .bind(id)
5199 .execute(store.pool())
5200 .await
5201 .unwrap();
5202
5203 let session_name = tmux.session_name("owner/repo", &format!("internal:{id}"));
5204 let created = tmux
5205 .command()
5206 .args([
5207 "new-session",
5208 "-d",
5209 "-s",
5210 &session_name,
5211 "-c",
5212 "/tmp",
5213 "sleep 60",
5214 ])
5215 .output()
5216 .await;
5217 match created {
5218 Ok(o) if o.status.success() => Some((id, session_name)),
5219 _ => None,
5220 }
5221 }
5222
5223 fn long_run_config() -> EngineConfig {
5224 EngineConfig {
5225 stuck_timeout: 1800,
5226 run_timeouts: RunTimeouts {
5227 base: 1800,
5228 medium: 2700,
5229 complex: 3600,
5230 },
5231 ..EngineConfig::default()
5232 }
5233 }
5234
5235 #[tokio::test]
5236 async fn run_tier_follows_the_tier_of_the_stored_model() {
5237 let mut router = RouterConfig::default();
5238 router.model_map.clear();
5239 for (tier, model) in [("simple", "rt-s"), ("complex", "rt-c")] {
5240 router
5241 .model_map
5242 .entry(tier.to_string())
5243 .or_default()
5244 .insert("rtagent".to_string(), vec![model.to_string()]);
5245 }
5246 let store = TaskStore::open_memory().await.unwrap();
5247 let id = store
5248 .create_internal(
5249 "owner/repo",
5250 "Simple on a complex model",
5251 "",
5252 "cron",
5253 "1",
5254 None,
5255 )
5256 .await
5257 .unwrap();
5258 store
5259 .set_fields(
5260 id,
5261 &[
5262 ("complexity", serde_json::json!("simple")),
5263 ("agent", serde_json::json!("rtagent")),
5264 ("model", serde_json::json!("rt-c")),
5265 ],
5266 )
5267 .await
5268 .unwrap();
5269 let task = store.get(id).await.unwrap();
5270 assert_eq!(run_tier(&router, &task), "complex");
5271
5272 store
5273 .set_fields(id, &[("model", serde_json::json!("rt-s"))])
5274 .await
5275 .unwrap();
5276 let task = store.get(id).await.unwrap();
5277 assert_eq!(run_tier(&router, &task), "simple");
5278 }
5279
5280 #[tokio::test]
5281 async fn recover_stuck_tasks_keeps_a_live_complex_run_inside_its_timeout() {
5282 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5283 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5284 let task_manager = Arc::new(TaskManager::with_store(
5285 Arc::clone(&backend),
5286 Arc::clone(&store),
5287 "owner/repo".to_string(),
5288 ));
5289 let tmux = Arc::new(TmuxManager::new());
5290 let Some((id, session_name)) = live_complex_run(&store, &tmux, 31, 31).await else {
5291 eprintln!("Skipping test: tmux not available or failed to create test session");
5292 return;
5293 };
5294 let session_map = tmux.batch_session_active().await;
5295
5296 tick_recover_stuck_tasks(
5297 &backend,
5298 &tmux,
5299 "owner/repo",
5300 &task_manager,
5301 &long_run_config(),
5302 &store,
5303 &session_map,
5304 &Arc::new(DashMap::new()),
5305 )
5306 .await
5307 .unwrap();
5308
5309 let alive = tmux.session_exists(&session_name).await;
5310 let _ = tmux.kill_session(&session_name).await;
5311 assert_eq!(
5312 store.get(id).await.unwrap().status,
5313 TaskStatus::InProgress,
5314 "a complex run at 31 minutes is inside its 3600s timeout"
5315 );
5316 assert!(alive, "recovery must not touch a run inside its timeout");
5317 }
5318
5319 #[tokio::test]
5320 async fn recover_stuck_tasks_kills_a_live_complex_run_past_its_timeout() {
5321 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5322 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5323 let task_manager = Arc::new(TaskManager::with_store(
5324 Arc::clone(&backend),
5325 Arc::clone(&store),
5326 "owner/repo".to_string(),
5327 ));
5328 let tmux = Arc::new(TmuxManager::new());
5329 let Some((id, session_name)) = live_complex_run(&store, &tmux, 66, 0).await else {
5330 eprintln!("Skipping test: tmux not available or failed to create test session");
5331 return;
5332 };
5333 let session_map = tmux.batch_session_active().await;
5334
5335 tick_recover_stuck_tasks(
5336 &backend,
5337 &tmux,
5338 "owner/repo",
5339 &task_manager,
5340 &long_run_config(),
5341 &store,
5342 &session_map,
5343 &Arc::new(DashMap::new()),
5344 )
5345 .await
5346 .unwrap();
5347
5348 let alive = tmux.session_exists(&session_name).await;
5349 let _ = tmux.kill_session(&session_name).await;
5350 assert_eq!(
5351 store.get(id).await.unwrap().status,
5352 TaskStatus::New,
5353 "a complex run past its timeout plus grace is stuck"
5354 );
5355 assert!(
5356 !alive,
5357 "recovery must kill the session it reports as killed"
5358 );
5359 }
5360
5361 #[tokio::test]
5375 async fn dispatch_does_not_deadlock_under_write_lock() {
5376 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5377 let mock = MockBackend::new();
5378 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5379 let task_manager = Arc::new(TaskManager::with_store(
5380 Arc::clone(&backend),
5381 Arc::clone(&store),
5382 "owner/repo".to_string(),
5383 ));
5384 let tmux = Arc::new(TmuxManager::new());
5385 let semaphore = Arc::new(Semaphore::new(4));
5386 let (weight_tx, _weight_rx) = mpsc::channel(16);
5387 let dispatching = Arc::new(DashMap::new());
5388 let transport = Arc::new(Transport::new());
5389 let capture = Arc::new(CaptureService::new(transport));
5390
5391 let router_config = RouterConfig::default();
5392 let router = Router::new_for_test(router_config, vec!["claude".to_string()]);
5393 let router_arc = Arc::new(RwLock::new(router));
5394
5395 let write_guard = router_arc.write().await;
5399 let dispatch_mode = dispatch_mode_from_router(&write_guard);
5400
5401 let result = time::timeout(
5402 Duration::from_secs(2),
5403 tick_dispatch_tasks(
5404 &backend,
5405 &tmux,
5406 "owner/repo",
5407 &Arc::new(TaskRunner::new("owner/repo".to_string())),
5408 &capture,
5409 &semaphore,
5410 &task_manager,
5411 &weight_tx,
5412 dispatch_mode,
5413 &dispatching,
5414 &store,
5415 &HashMap::new(),
5416 ),
5417 )
5418 .await;
5419
5420 assert!(
5421 result.is_ok(),
5422 "tick_dispatch_tasks deadlocked! It tried to acquire a read lock \
5423 on router_arc while a write lock was already held (issue #1361)"
5424 );
5425 }
5426
5427 #[tokio::test]
5435 async fn dispatch_skips_task_whose_routed_model_entered_cooldown() {
5436 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5437 let mock = MockBackend::new();
5438 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5439 let task_manager = Arc::new(TaskManager::with_store(
5440 Arc::clone(&backend),
5441 Arc::clone(&store),
5442 "owner/repo".to_string(),
5443 ));
5444 let tmux = Arc::new(TmuxManager::new());
5445 let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
5446 let semaphore = Arc::new(Semaphore::new(4));
5447 let (weight_tx, _weight_rx) = mpsc::channel(16);
5448 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5449 let transport = Arc::new(Transport::new());
5450 let capture = Arc::new(CaptureService::new(transport));
5451
5452 let id = store
5453 .upsert_external(&UpsertExternal {
5454 repo: "owner/repo",
5455 ext_id: "999",
5456 title: "Stale routed task",
5457 body: "",
5458 author: "user",
5459 url: "https://github.com/owner/repo/issues/999",
5460 labels: &[],
5461 origin: "github",
5462 })
5463 .await
5464 .unwrap();
5465 store
5466 .store_route(&StoreRoute {
5467 id,
5468 agent: "minimax",
5469 model: Some("opus"),
5470 complexity: "medium",
5471 estimate: 0,
5472 reason: "test",
5473 profile: "{}",
5474 skills: "[]",
5475 })
5476 .await
5477 .unwrap();
5478 store.update_status(id, DbStatus::Routed).await.unwrap();
5479
5480 cooldown::set_model_cooldown("minimax", "opus", 300, "rate_limit_usage_window").await;
5483
5484 let dispatch_mode = DispatchMode {
5485 is_degraded: false,
5486 healthy_agents: 1,
5487 threshold: 1,
5488 };
5489
5490 tick_dispatch_tasks(
5491 &backend,
5492 &tmux,
5493 "owner/repo",
5494 &runner,
5495 &capture,
5496 &semaphore,
5497 &task_manager,
5498 &weight_tx,
5499 dispatch_mode,
5500 &dispatching,
5501 &store,
5502 &HashMap::new(),
5503 )
5504 .await
5505 .unwrap();
5506
5507 let task = store.get(id).await.unwrap();
5508 assert_eq!(
5509 task.status,
5510 DbStatus::Routed,
5511 "task whose routed model is now cooled down must stay Routed, not be dispatched"
5512 );
5513 assert!(
5514 dispatching.is_empty(),
5515 "dispatch guard must be released when skipping a stale-cooldown task"
5516 );
5517 }
5518
5519 #[tokio::test]
5520 async fn dispatch_skips_a_task_with_an_open_dependency() {
5521 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5522 let mock = MockBackend::new();
5523 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5524 let task_manager = Arc::new(TaskManager::with_store(
5525 Arc::clone(&backend),
5526 Arc::clone(&store),
5527 "owner/repo".to_string(),
5528 ));
5529 let tmux = Arc::new(TmuxManager::new());
5530 let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
5531 let semaphore = Arc::new(Semaphore::new(4));
5532 let (weight_tx, _weight_rx) = mpsc::channel(16);
5533 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5534 let transport = Arc::new(Transport::new());
5535 let capture = Arc::new(CaptureService::new(transport));
5536
5537 let id = store
5538 .upsert_external(&UpsertExternal {
5539 repo: "owner/repo",
5540 ext_id: "42",
5541 title: "Waiting routed task",
5542 body: "",
5543 author: "user",
5544 url: "https://github.com/owner/repo/issues/42",
5545 labels: &[],
5546 origin: "github",
5547 })
5548 .await
5549 .unwrap();
5550 store
5551 .replace_dependencies(
5552 id,
5553 &[NewDependency {
5554 repo: "owner/repo".into(),
5555 number: 7,
5556 source: DepSource::Github,
5557 }],
5558 )
5559 .await
5560 .unwrap();
5561 store
5562 .store_route(&StoreRoute {
5563 id,
5564 agent: "claude",
5565 model: Some("haiku"),
5566 complexity: "simple",
5567 estimate: 0,
5568 reason: "test",
5569 profile: "{}",
5570 skills: "[]",
5571 })
5572 .await
5573 .unwrap();
5574 store.update_status(id, DbStatus::Routed).await.unwrap();
5575
5576 let dispatch_mode = DispatchMode {
5577 is_degraded: false,
5578 healthy_agents: 1,
5579 threshold: 1,
5580 };
5581 tick_dispatch_tasks(
5582 &backend,
5583 &tmux,
5584 "owner/repo",
5585 &runner,
5586 &capture,
5587 &semaphore,
5588 &task_manager,
5589 &weight_tx,
5590 dispatch_mode,
5591 &dispatching,
5592 &store,
5593 &HashMap::new(),
5594 )
5595 .await
5596 .unwrap();
5597
5598 let task = store.get(id).await.unwrap();
5599 assert_eq!(task.status, DbStatus::Waiting);
5600 assert!(dispatching.is_empty());
5601 }
5602
5603 #[tokio::test]
5616 async fn completed_session_does_not_cause_stuck_reclaim_race() {
5617 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5618 let mock = MockBackend::new();
5619 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5620 let task_manager = Arc::new(TaskManager::with_store(
5621 Arc::clone(&backend),
5622 Arc::clone(&store),
5623 "owner/repo".to_string(),
5624 ));
5625 let tmux = Arc::new(TmuxManager::new());
5626 let transport = Arc::new(Transport::new());
5627 let capture = Arc::new(CaptureService::new(transport));
5628
5629 let id = store
5631 .upsert_external(&crate::store::UpsertExternal {
5632 repo: "owner/repo",
5633 ext_id: "200",
5634 title: "Race Task",
5635 body: "",
5636 author: "",
5637 url: "",
5638 labels: &[],
5639 origin: "github",
5640 })
5641 .await
5642 .unwrap();
5643 store
5644 .update_status(id, TaskStatus::InProgress)
5645 .await
5646 .unwrap();
5647
5648 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
5650 .bind(id)
5651 .execute(store.pool())
5652 .await
5653 .unwrap();
5654
5655 let task_id = "200";
5657 let session_name = tmux.session_name("owner/repo", task_id);
5658 let create_result = tmux
5659 .command()
5660 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
5661 .output()
5662 .await;
5663 match create_result {
5664 Ok(o) if o.status.success() => {}
5665 _ => {
5666 eprintln!("Skipping test: tmux not available or failed to create test session");
5667 return;
5668 }
5669 }
5670 let _guard = SessionGuard(session_name.clone());
5671
5672 let _ = tmux
5674 .command()
5675 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
5676 .output()
5677 .await;
5678 let _ = tmux
5679 .command()
5680 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
5681 .output()
5682 .await;
5683
5684 for _ in 0..20 {
5686 time::sleep(Duration::from_millis(100)).await;
5687 if !tmux.session_is_running(&session_name).await {
5688 break;
5689 }
5690 }
5691
5692 let task_key = task_id.to_string();
5694 capture
5695 .register_session("owner/repo", &task_key, &session_name)
5696 .await;
5697
5698 let config = EngineConfig {
5700 no_session_stuck_timeout: 600,
5701 stuck_timeout: 1800,
5702 ..EngineConfig::default()
5703 };
5704
5705 tick_check_session_completions(&tmux, "owner/repo", &capture, &store)
5706 .await
5707 .unwrap();
5708
5709 {
5715 let task_state = store.get(id).await.unwrap();
5716 if task_state.updated_at.starts_with("2020") {
5717 eprintln!(
5718 "Skipping test: tick_check_session_completions did not update stored \
5719 updated_at — session was not in snapshot (CI tmux environment issue)"
5720 );
5721 return;
5722 }
5723 }
5724
5725 tick_recover_stuck_tasks(
5727 &backend,
5728 &tmux,
5729 "owner/repo",
5730 &task_manager,
5731 &config,
5732 &store,
5733 &HashMap::new(),
5734 &Arc::new(DashMap::new()),
5735 )
5736 .await
5737 .unwrap();
5738
5739 let task = store.get(id).await.unwrap();
5741 assert_eq!(
5742 task.status,
5743 TaskStatus::InProgress,
5744 "task should not be reclaimed to New"
5745 );
5746 }
5747
5748 #[tokio::test]
5758 async fn session_completion_matches_repo_configured_with_git_suffix() {
5759 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5760 let tmux = Arc::new(TmuxManager::new());
5761 let transport = Arc::new(Transport::new());
5762 let capture = Arc::new(CaptureService::new(transport));
5763
5764 let repo = "owner/repo.git";
5765 let id = store
5766 .upsert_external(&crate::store::UpsertExternal {
5767 repo,
5768 ext_id: "201",
5769 title: "Git-suffix repo task",
5770 body: "",
5771 author: "",
5772 url: "",
5773 labels: &[],
5774 origin: "github",
5775 })
5776 .await
5777 .unwrap();
5778 store
5779 .update_status(id, TaskStatus::InProgress)
5780 .await
5781 .unwrap();
5782 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
5783 .bind(id)
5784 .execute(store.pool())
5785 .await
5786 .unwrap();
5787
5788 let task_id = "201";
5791 let session_name = tmux.session_name(repo, task_id);
5792 let create_result = tmux
5793 .command()
5794 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
5795 .output()
5796 .await;
5797 match create_result {
5798 Ok(o) if o.status.success() => {}
5799 _ => {
5800 eprintln!("Skipping test: tmux not available or failed to create test session");
5801 return;
5802 }
5803 }
5804 let _guard = SessionGuard(session_name.clone());
5805
5806 let _ = tmux
5807 .command()
5808 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
5809 .output()
5810 .await;
5811 let _ = tmux
5812 .command()
5813 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
5814 .output()
5815 .await;
5816
5817 for _ in 0..20 {
5818 time::sleep(Duration::from_millis(100)).await;
5819 if !tmux.session_is_running(&session_name).await {
5820 break;
5821 }
5822 }
5823
5824 capture.register_session(repo, task_id, &session_name).await;
5825
5826 tick_check_session_completions(&tmux, repo, &capture, &store)
5827 .await
5828 .unwrap();
5829
5830 let task_state = store.get(id).await.unwrap();
5831 if task_state.updated_at.starts_with("2020") {
5832 eprintln!(
5833 "Skipping test: tick_check_session_completions did not update stored \
5834 updated_at — session was not in snapshot (CI tmux environment issue)"
5835 );
5836 return;
5837 }
5838 assert!(
5839 !task_state.updated_at.starts_with("2020"),
5840 "tick_check_session_completions must match its own repo's live sessions \
5841 even when the repo is configured with a \".git\" suffix"
5842 );
5843 }
5844
5845 #[tokio::test]
5851 async fn job_scheduler_returns_ok_on_duplicate_job_config() {
5852 let tmp = tempfile::tempdir().unwrap();
5853 let cfg = tmp.path().join(".orch.yml");
5854 fs::write(
5855 &cfg,
5856 "jobs:\n - id: trading-scan\n schedule: \"0 9 * * *\"\n task:\n title: Scan\n body: do it\n",
5857 )
5858 .unwrap();
5859 let jobs_dir = tmp.path().join("prompts").join("jobs");
5860 fs::create_dir_all(&jobs_dir).unwrap();
5861 fs::write(
5862 jobs_dir.join("trading-scan.md"),
5863 "---\nid: trading-scan\nschedule: '0 9 * * *'\ntitle: Trading Scan\n---\n\nDo trading scan.\n",
5864 )
5865 .unwrap();
5866
5867 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5868 let result = tick_job_scheduler(&cfg, &backend, None, "test/repo", None).await;
5869 assert!(
5870 result.is_ok(),
5871 "duplicate job id must not propagate as Err: {result:?}"
5872 );
5873 }
5874
5875 #[tokio::test]
5879 async fn job_scheduler_processes_valid_project_when_other_has_duplicate() {
5880 let bad_tmp = tempfile::tempdir().unwrap();
5882 let bad_cfg = bad_tmp.path().join(".orch.yml");
5883 fs::write(
5884 &bad_cfg,
5885 "jobs:\n - id: dup\n schedule: \"0 9 * * *\"\n task:\n title: Dup\n body: x\n",
5886 )
5887 .unwrap();
5888 let bad_jobs_dir = bad_tmp.path().join("prompts").join("jobs");
5889 fs::create_dir_all(&bad_jobs_dir).unwrap();
5890 fs::write(
5891 bad_jobs_dir.join("dup.md"),
5892 "---\nid: dup\nschedule: '0 9 * * *'\ntitle: Dup\n---\n\nbody\n",
5893 )
5894 .unwrap();
5895
5896 let good_tmp = tempfile::tempdir().unwrap();
5898 let good_cfg = good_tmp.path().join(".orch.yml");
5899 fs::write(
5900 &good_cfg,
5901 "jobs:\n - id: valid-job\n schedule: \"0 9 * * *\"\n task:\n title: Valid\n body: ok\n",
5902 )
5903 .unwrap();
5904
5905 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5906
5907 let bad_result = tick_job_scheduler(&bad_cfg, &backend, None, "test/bad-repo", None).await;
5908 assert!(
5909 bad_result.is_ok(),
5910 "bad project must not abort scheduler: {bad_result:?}"
5911 );
5912
5913 let good_result =
5914 tick_job_scheduler(&good_cfg, &backend, None, "test/good-repo", None).await;
5915 assert!(
5916 good_result.is_ok(),
5917 "valid project must still run after bad project: {good_result:?}"
5918 );
5919 }
5920
5921 #[serial_test::serial(cooldown_state)]
5929 #[tokio::test]
5930 async fn global_sweep_routes_inactive_repo_tasks() {
5931 cooldown::reset_global_state().await;
5932
5933 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5934
5935 let active_id = store
5937 .upsert_external(&UpsertExternal {
5938 repo: "active/repo",
5939 ext_id: "1",
5940 title: "Active task",
5941 body: "",
5942 author: "user",
5943 url: "https://github.com/active/repo/issues/1",
5944 labels: &[],
5945 origin: "github",
5946 })
5947 .await
5948 .unwrap();
5949
5950 let inactive_id = store
5952 .upsert_external(&UpsertExternal {
5953 repo: "inactive/repo",
5954 ext_id: "42",
5955 title: "Orphaned task",
5956 body: "",
5957 author: "user",
5958 url: "https://github.com/inactive/repo/issues/42",
5959 labels: &[],
5960 origin: "github",
5961 })
5962 .await
5963 .unwrap();
5964
5965 assert_eq!(store.get(active_id).await.unwrap().status, TaskStatus::New);
5966 assert_eq!(
5967 store.get(inactive_id).await.unwrap().status,
5968 TaskStatus::New
5969 );
5970
5971 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
5972 let mut tier = HashMap::new();
5973 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5974 model_map.insert("medium".to_string(), tier);
5975 let config = RouterConfig {
5976 mode: "round_robin".to_string(),
5977 agents: vec!["claude".to_string()],
5978 model_map,
5979 ..Default::default()
5980 };
5981 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5982
5983 let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
5984 let mut routing_quota = RoutingTickQuota::new(1);
5985 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5986 .await
5987 .unwrap();
5988
5989 assert_eq!(
5991 store.get(active_id).await.unwrap().status,
5992 TaskStatus::New,
5993 "global sweep must not touch tasks for the active repo"
5994 );
5995
5996 let after = store.get(inactive_id).await.unwrap();
5998 assert_eq!(
5999 after.status,
6000 TaskStatus::Routed,
6001 "global sweep must route tasks from inactive repos"
6002 );
6003 assert!(
6004 matches!(&after.agent, Some(a) if !a.is_empty()),
6005 "agent must be set after routing"
6006 );
6007 }
6008
6009 #[serial_test::serial(cooldown_state)]
6017 #[tokio::test]
6018 async fn global_sweep_does_not_touch_other_active_repo_tasks() {
6019 cooldown::reset_global_state().await;
6020
6021 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6022
6023 let other_active_id = store
6026 .upsert_external(&UpsertExternal {
6027 repo: "other-active/repo",
6028 ext_id: "7",
6029 title: "Other active project's task",
6030 body: "",
6031 author: "user",
6032 url: "https://github.com/other-active/repo/issues/7",
6033 labels: &[],
6034 origin: "github",
6035 })
6036 .await
6037 .unwrap();
6038
6039 let inactive_id = store
6041 .upsert_external(&UpsertExternal {
6042 repo: "inactive/repo",
6043 ext_id: "42",
6044 title: "Orphaned task",
6045 body: "",
6046 author: "user",
6047 url: "https://github.com/inactive/repo/issues/42",
6048 labels: &[],
6049 origin: "github",
6050 })
6051 .await
6052 .unwrap();
6053
6054 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
6055 let mut tier = HashMap::new();
6056 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
6057 model_map.insert("medium".to_string(), tier);
6058 let config = RouterConfig {
6059 mode: "round_robin".to_string(),
6060 agents: vec!["claude".to_string()],
6061 model_map,
6062 ..Default::default()
6063 };
6064 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
6065
6066 let active_repos: HashSet<String> =
6070 ["active/repo".to_string(), "other-active/repo".to_string()]
6071 .into_iter()
6072 .collect();
6073 let mut routing_quota = RoutingTickQuota::new(1);
6074 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
6075 .await
6076 .unwrap();
6077
6078 assert_eq!(
6079 store.get(other_active_id).await.unwrap().status,
6080 TaskStatus::New,
6081 "global sweep run from a different active repo's tick must not route \
6082 another active repo's new tasks — that repo's own tick will route them"
6083 );
6084 assert_eq!(
6085 store.get(inactive_id).await.unwrap().status,
6086 TaskStatus::Routed,
6087 "global sweep must still route tasks from genuinely orphaned repos"
6088 );
6089 }
6090
6091 #[serial_test::serial(cooldown_state)]
6093 #[tokio::test]
6094 async fn global_sweep_skips_no_agent_tasks() {
6095 cooldown::reset_global_state().await;
6096
6097 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6098
6099 let id = store
6100 .upsert_external(&UpsertExternal {
6101 repo: "inactive/repo",
6102 ext_id: "99",
6103 title: "No-agent task",
6104 body: "",
6105 author: "user",
6106 url: "https://github.com/inactive/repo/issues/99",
6107 labels: &["no-agent".to_string()],
6108 origin: "github",
6109 })
6110 .await
6111 .unwrap();
6112
6113 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
6114 let mut tier = HashMap::new();
6115 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
6116 model_map.insert("medium".to_string(), tier);
6117 let config = RouterConfig {
6118 mode: "round_robin".to_string(),
6119 agents: vec!["claude".to_string()],
6120 model_map,
6121 ..Default::default()
6122 };
6123 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
6124
6125 let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
6126 let mut routing_quota = RoutingTickQuota::new(1);
6127 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
6128 .await
6129 .unwrap();
6130
6131 assert_eq!(
6132 store.get(id).await.unwrap().status,
6133 TaskStatus::New,
6134 "no-agent tasks must not be routed"
6135 );
6136 }
6137
6138 #[serial_test::serial(cooldown_state)]
6143 #[tokio::test]
6144 async fn global_sweep_honors_shared_routing_quota() {
6145 cooldown::reset_global_state().await;
6146
6147 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6148 for ext_id in ["1", "2"] {
6149 store
6150 .upsert_external(&UpsertExternal {
6151 repo: "inactive/repo",
6152 ext_id,
6153 title: "Orphaned task",
6154 body: "",
6155 author: "user",
6156 url: &format!("https://github.com/inactive/repo/issues/{ext_id}"),
6157 labels: &[],
6158 origin: "github",
6159 })
6160 .await
6161 .unwrap();
6162 }
6163
6164 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
6165 let mut tier = HashMap::new();
6166 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
6167 model_map.insert("medium".to_string(), tier);
6168 let config = RouterConfig {
6169 mode: "round_robin".to_string(),
6170 agents: vec!["claude".to_string()],
6171 model_map,
6172 ..Default::default()
6173 };
6174 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
6175
6176 let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
6177 let mut routing_quota = RoutingTickQuota::new(1);
6178 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
6179 .await
6180 .unwrap();
6181
6182 let all_tasks = store.list_all_global().await.unwrap();
6183 let routed = all_tasks
6184 .iter()
6185 .filter(|task| task.status == TaskStatus::Routed)
6186 .count();
6187 let still_new = all_tasks
6188 .iter()
6189 .filter(|task| task.status == TaskStatus::New)
6190 .count();
6191
6192 assert_eq!(routed, 1, "only one task may consume the shared quota");
6193 assert_eq!(still_new, 1, "remaining tasks wait for the next tick");
6194
6195 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
6196 .await
6197 .unwrap();
6198 let after_exhausted = store.list_all_global().await.unwrap();
6199 assert_eq!(
6200 after_exhausted
6201 .iter()
6202 .filter(|task| task.status == TaskStatus::Routed)
6203 .count(),
6204 1,
6205 "an exhausted shared quota must not reset within the same tick"
6206 );
6207 }
6208
6209 #[tokio::test]
6226 async fn group_orphaned_routed_tasks_selects_inactive_repo_tasks() {
6227 async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
6228 let id = store
6229 .upsert_external(&UpsertExternal {
6230 repo,
6231 ext_id,
6232 title: "Routed task",
6233 body: "",
6234 author: "user",
6235 url: &format!("https://github.com/{repo}/issues/{ext_id}"),
6236 labels: &[],
6237 origin: "github",
6238 })
6239 .await
6240 .unwrap();
6241 store
6242 .store_route(&StoreRoute {
6243 id,
6244 agent: "claude",
6245 model: Some("sonnet"),
6246 complexity: "medium",
6247 estimate: 0,
6248 reason: "test",
6249 profile: "{}",
6250 skills: "[]",
6251 })
6252 .await
6253 .unwrap();
6254 store.update_status(id, TaskStatus::Routed).await.unwrap();
6255 id
6256 }
6257
6258 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6259
6260 let active_id = make_routed(&store, "active/repo", "1").await;
6261 let inactive_id_a = make_routed(&store, "inactive/repo", "2").await;
6262 let other_inactive_id = make_routed(&store, "other-inactive/repo", "3").await;
6263 let inactive_id_b = make_routed(&store, "inactive/repo", "4").await;
6264
6265 let all_routed = store
6266 .list_all_by_status_global(TaskStatus::Routed)
6267 .await
6268 .unwrap();
6269 let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
6270 let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
6271
6272 assert!(
6273 !by_repo.contains_key("active/repo"),
6274 "global sweep must not select tasks for the active repo"
6275 );
6276 let inactive_ids: HashSet<i64> = by_repo
6277 .get("inactive/repo")
6278 .expect("inactive/repo must be selected")
6279 .iter()
6280 .map(|t| t.id)
6281 .collect();
6282 assert_eq!(
6283 inactive_ids,
6284 [inactive_id_a, inactive_id_b].into_iter().collect(),
6285 "global sweep must group all orphaned tasks for a repo together"
6286 );
6287 assert_eq!(
6288 by_repo
6289 .get("other-inactive/repo")
6290 .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
6291 Some(vec![other_inactive_id])
6292 );
6293
6294 assert_eq!(
6297 store.get(active_id).await.unwrap().status,
6298 TaskStatus::Routed
6299 );
6300 }
6301
6302 #[tokio::test]
6307 async fn group_orphaned_routed_tasks_excludes_all_active_repos_not_just_current() {
6308 async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
6309 let id = store
6310 .upsert_external(&UpsertExternal {
6311 repo,
6312 ext_id,
6313 title: "Routed task",
6314 body: "",
6315 author: "user",
6316 url: &format!("https://github.com/{repo}/issues/{ext_id}"),
6317 labels: &[],
6318 origin: "github",
6319 })
6320 .await
6321 .unwrap();
6322 store
6323 .store_route(&StoreRoute {
6324 id,
6325 agent: "claude",
6326 model: Some("sonnet"),
6327 complexity: "medium",
6328 estimate: 0,
6329 reason: "test",
6330 profile: "{}",
6331 skills: "[]",
6332 })
6333 .await
6334 .unwrap();
6335 store.update_status(id, TaskStatus::Routed).await.unwrap();
6336 id
6337 }
6338
6339 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6340
6341 let repo_a_id = make_routed(&store, "active/repo-a", "1").await;
6342 let repo_b_id = make_routed(&store, "active/repo-b", "2").await;
6343 let orphaned_id = make_routed(&store, "inactive/repo", "3").await;
6344
6345 let all_routed = store
6346 .list_all_by_status_global(TaskStatus::Routed)
6347 .await
6348 .unwrap();
6349 let active_repos: HashSet<String> =
6352 ["active/repo-a".to_string(), "active/repo-b".to_string()]
6353 .into_iter()
6354 .collect();
6355 let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
6356
6357 assert!(
6358 !by_repo.contains_key("active/repo-a"),
6359 "the current repo's own tasks must not be swept"
6360 );
6361 assert!(
6362 !by_repo.contains_key("active/repo-b"),
6363 "another active repo's tasks must not be swept during repo A's tick"
6364 );
6365 assert_eq!(
6366 by_repo
6367 .get("inactive/repo")
6368 .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
6369 Some(vec![orphaned_id]),
6370 "a genuinely inactive repo's tasks must still be swept"
6371 );
6372
6373 assert_eq!(
6375 store.get(repo_a_id).await.unwrap().status,
6376 TaskStatus::Routed
6377 );
6378 assert_eq!(
6379 store.get(repo_b_id).await.unwrap().status,
6380 TaskStatus::Routed
6381 );
6382 }
6383
6384 #[tokio::test]
6386 async fn global_dispatch_sweep_skips_no_agent_tasks() {
6387 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6388
6389 let id = store
6390 .upsert_external(&UpsertExternal {
6391 repo: "inactive/repo",
6392 ext_id: "99",
6393 title: "No-agent routed task",
6394 body: "",
6395 author: "user",
6396 url: "https://github.com/inactive/repo/issues/99",
6397 labels: &["no-agent".to_string()],
6398 origin: "github",
6399 })
6400 .await
6401 .unwrap();
6402 store
6403 .store_route(&StoreRoute {
6404 id,
6405 agent: "claude",
6406 model: Some("sonnet"),
6407 complexity: "medium",
6408 estimate: 0,
6409 reason: "test",
6410 profile: "{}",
6411 skills: "[]",
6412 })
6413 .await
6414 .unwrap();
6415 store.update_status(id, TaskStatus::Routed).await.unwrap();
6416
6417 let tmux = Arc::new(TmuxManager::new());
6422 let transport = Arc::new(Transport::new());
6423 let capture = Arc::new(CaptureService::new(transport));
6424 let semaphore = Arc::new(Semaphore::new(4));
6425 let (weight_tx, _weight_rx) = mpsc::channel(16);
6426 let dispatching = Arc::new(DashMap::new());
6427 let dispatch_mode = DispatchMode {
6428 is_degraded: false,
6429 healthy_agents: 1,
6430 threshold: 1,
6431 };
6432
6433 let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
6434 dispatch_routed_tasks_global(
6435 &tmux,
6436 &capture,
6437 &semaphore,
6438 &weight_tx,
6439 dispatch_mode,
6440 &dispatching,
6441 &store,
6442 &HashMap::new(),
6443 &active_repos,
6444 )
6445 .await
6446 .unwrap();
6447
6448 assert_eq!(
6449 store.get(id).await.unwrap().status,
6450 TaskStatus::Routed,
6451 "no-agent tasks must not be dispatched"
6452 );
6453 }
6454}