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