1use crate::backends::{ExternalBackend, ExternalId, ExternalTask, Status};
13use crate::channels::capture::CaptureService;
14use crate::config;
15use crate::engine::cooldown::{
16 github_circuit_remaining_secs, is_agent_in_cooldown, is_github_circuit_open,
17 is_model_in_cooldown, record_agent_failure_with_message, record_silence_detection,
18 set_agent_cooldown, set_model_cooldown, SILENCE_AGENT_COOLDOWN_SECS,
19 SILENCE_EXTENDED_COOLDOWN_SECS,
20};
21use crate::engine::dispatch_guard::DispatchGuard;
22use crate::engine::jobs;
23use crate::engine::router::{get_route_result, AllAgentsCooledError, RouteResultError, Router};
24use crate::engine::runner::{TaskRunner, WeightSignal};
25use crate::engine::tasks::{is_internal_id, TaskManager};
26use crate::repo_context::REPO_CONTEXT;
27use crate::store::TaskStore;
28use crate::tmux::TmuxManager;
29use dashmap::{DashMap, DashSet};
30use std::sync::atomic::{AtomicBool, Ordering};
31use std::sync::Arc;
32use std::sync::LazyLock;
33use std::time::{Instant, SystemTime};
34use tokio::sync::{mpsc, Semaphore};
35
36static ENGINE_START_TIME: LazyLock<u64> = LazyLock::new(|| {
39 SystemTime::now()
40 .duration_since(SystemTime::UNIX_EPOCH)
41 .unwrap_or_default()
42 .as_secs()
43});
44
45static STARTUP_CLEANUP_DONE: AtomicBool = AtomicBool::new(false);
47
48pub(crate) struct RoutingTickQuota {
55 remaining: usize,
56}
57
58impl RoutingTickQuota {
59 pub(crate) fn new(max: usize) -> Self {
60 Self { remaining: max }
61 }
62
63 fn is_exhausted(&self) -> bool {
64 self.remaining == 0
65 }
66
67 fn remaining(&self) -> usize {
68 self.remaining
69 }
70
71 fn consume_one(&mut self) -> bool {
72 if self.remaining == 0 {
73 return false;
74 }
75 self.remaining -= 1;
76 true
77 }
78}
79
80async fn startup_cleanup(tmux: &TmuxManager) {
83 if STARTUP_CLEANUP_DONE
85 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
86 .is_err()
87 {
88 return;
89 }
90
91 let start_time = *ENGINE_START_TIME;
92 tracing::info!(
93 start_time,
94 "running startup cleanup for stale tmux sessions"
95 );
96
97 match tmux.kill_stale_sessions(start_time).await {
98 Ok(killed) => {
99 if killed > 0 {
100 tracing::info!(killed, "startup cleanup killed stale sessions");
101 } else {
102 tracing::debug!("no stale tmux sessions found on startup");
103 }
104 }
105 Err(e) => {
106 tracing::warn!(error = %e, "failed to run startup cleanup");
107 }
108 }
109}
110
111use super::EngineConfig;
112use crate::store::{set_review_session_expected, store_set_by_id, store_touch_updated_at};
113
114async fn recover_routed_blocked_dispatch(
115 backend: &Arc<dyn ExternalBackend>,
116 store: &Arc<TaskStore>,
117 task_manager: &Arc<TaskManager>,
118 repo: &str,
119 task: &ExternalTask,
120 timing: &StuckTaskTiming,
121) {
122 tracing::warn!(
123 task_id = task.id.0,
124 age_mins = timing.age.num_minutes(),
125 threshold_mins = timing.threshold / 60,
126 "recovering routed task: dispatch blocked by stale tmux session → new"
127 );
128
129 let resolved_store_id = match store.resolve_task_id(repo, &task.id.0).await {
130 Ok(Some(id)) => Some(id),
131 Ok(None) => {
132 tracing::warn!(
133 task_id = %task.id.0,
134 repo,
135 "resolve_task_id returned None during routed-task recovery"
136 );
137 None
138 }
139 Err(e) => {
140 tracing::error!(
141 task_id = %task.id.0,
142 repo,
143 error = %e,
144 "resolve_task_id failed during routed-task recovery"
145 );
146 None
147 }
148 };
149
150 if let Some(store_id) = resolved_store_id {
151 if let Err(e) = store_set_by_id(
152 &Some(store),
153 store_id,
154 &[
155 ("agent", serde_json::Value::Null),
156 ("model", serde_json::Value::Null),
157 ("route_attempts", serde_json::json!(0)),
158 ],
159 )
160 .await
161 {
162 tracing::warn!(
163 task_id = task.id.0,
164 ?e,
165 "failed to clear routing fields for routed stuck task — will retry next tick"
166 );
167 return;
168 }
169 finalize_open_runs_for_recovery(
170 store,
171 store_id,
172 "stuck-task recovery: stale session blocking dispatch killed",
173 )
174 .await;
175 }
176
177 if let Err(e) = task_manager.update_task_status(&task.id, Status::New).await {
178 tracing::warn!(
179 task_id = task.id.0,
180 ?e,
181 "failed to reset routed stuck task status"
182 );
183 return;
184 }
185
186 crate::store::store_log_activity(
187 &Some(Arc::clone(store)),
188 repo,
189 &task.id.0,
190 "rerouted",
191 Some("routed"),
192 Some("new"),
193 None::<&str>,
194 None::<&str>,
195 Some(&serde_json::json!({
196 "failure_reason": "routed_blocked_by_existing_session",
197 "age_minutes": timing.age.num_minutes(),
198 "had_session": true,
199 })),
200 )
201 .await;
202
203 if !is_internal_id(&task.id.0) {
204 let backend_clone = Arc::clone(backend);
205 let task_id_clone = task.id.clone();
206 let body = format!(
207 "[{}] recovered: stuck routed task — existing tmux session blocked dispatch for {}m; cleared route for retry{}",
208 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
209 timing.age.num_minutes(),
210 crate::engine::orch_footer()
211 );
212 tokio::spawn(async move {
213 if let Err(e) = backend_clone.post_comment(&task_id_clone, &body).await {
214 tracing::warn!(
215 task_id = task_id_clone.0,
216 ?e,
217 "failed to post routed-task recovery comment"
218 );
219 }
220 });
221 }
222}
223
224pub(crate) async fn tick_check_session_completions(
226 tmux: &Arc<TmuxManager>,
227 repo: &str,
228 capture: &Arc<CaptureService>,
229 store: &Arc<TaskStore>,
230) -> anyhow::Result<()> {
231 let _span = tracing::info_span!("engine.tick.phase1.sessions").entered();
232 let session_snapshot = tmux.snapshot().await;
233 let repo_name = repo
239 .rsplit('/')
240 .next()
241 .unwrap_or(repo)
242 .trim_end_matches(".git");
243 for (session, active) in session_snapshot {
244 if session.project != repo_name {
246 continue;
247 }
248
249 if !active {
250 tracing::info!(
251 session = %session.name,
252 task_id = %session.task_id,
253 "session completed, collecting results"
254 );
255 let store_task_id = {
266 let base = session
267 .task_id
268 .strip_suffix("-review")
269 .unwrap_or(&session.task_id);
270 if let Some(n) = base.strip_prefix("internal-") {
271 format!("internal:{n}")
272 } else {
273 base.to_string()
274 }
275 };
276 store_touch_updated_at(&Some(Arc::clone(store)), repo, &store_task_id).await;
277 capture.unregister_session(repo, &session.task_id).await;
280 if let Err(e) = tmux.kill_session(&session.name).await {
282 tracing::debug!(
283 session = %session.name,
284 ?e,
285 "kill_session failed (session may already be gone)"
286 );
287 }
288 }
289 }
290 Ok(())
291}
292
293#[derive(Debug)]
294struct StuckTaskTiming {
295 has_session: bool,
296 age: chrono::Duration,
297 threshold: u64,
298}
299
300#[allow(clippy::too_many_arguments)]
301fn stuck_task_timing_from_map(
302 tmux: &Arc<TmuxManager>,
303 repo: &str,
304 session_task_id: &str,
305 updated_at: &str,
306 config: &EngineConfig,
307 parse_error_message: &'static str,
308 session_map: &std::collections::HashMap<String, bool>,
309) -> Option<StuckTaskTiming> {
310 let session_name = tmux.session_name(repo, session_task_id);
311 let has_session = session_map.get(&session_name).copied().unwrap_or(false);
313 let threshold = if has_session {
314 config.stuck_timeout
315 } else {
316 config.no_session_stuck_timeout
317 };
318
319 let updated = match chrono::DateTime::parse_from_rfc3339(updated_at) {
320 Ok(dt) => dt.with_timezone(&chrono::Utc),
321 Err(e) => {
322 tracing::warn!(
323 task_id = session_task_id,
324 updated_at,
325 ?e,
326 error_message = parse_error_message,
327 "cannot parse updated_at"
328 );
329 return None;
330 }
331 };
332
333 let raw_age = chrono::Utc::now() - updated;
334 let suspended = super::suspend::suspended_duration_since(updated);
338 let age = (raw_age - suspended).max(chrono::Duration::zero());
339 if age.num_seconds() <= threshold as i64 {
340 return None;
341 }
342
343 Some(StuckTaskTiming {
344 has_session,
345 age,
346 threshold,
347 })
348}
349
350async fn finalize_open_runs_for_recovery(store: &TaskStore, store_id: i64, reason: &str) {
353 if let Err(e) = store
354 .finalize_incomplete_runs(store_id, "aborted", reason)
355 .await
356 {
357 tracing::warn!(
358 store_id,
359 error = %e,
360 reason,
361 "failed to finalize incomplete runs during recovery"
362 );
363 }
364}
365
366pub(crate) async fn tick_detect_silent_agents(
375 tmux: &Arc<TmuxManager>,
376 repo: &str,
377 capture: &Arc<CaptureService>,
378 backend: &Arc<dyn ExternalBackend>,
379 task_manager: &Arc<TaskManager>,
380 config: &EngineConfig,
381 store: &Arc<TaskStore>,
382) -> anyhow::Result<()> {
383 let _span = tracing::info_span!("engine.tick.phase1b.silence").entered();
384 let grace = std::time::Duration::from_secs(config.silence_grace_period);
385 let silent_sessions = capture.get_silent_sessions_for_repo(repo, grace).await;
386
387 for (task_id, session_name, silence_age_secs) in silent_sessions {
388 let use_backend = should_use_backend(&task_id);
389 let store_task = match store.resolve_task_id(repo, &task_id).await {
391 Ok(Some(store_id)) => match store.get(store_id).await {
392 Ok(task) => Some(task),
393 Err(e) => {
394 tracing::warn!(
395 task_id,
396 repo,
397 store_id,
398 operation = "store.get",
399 error = %e,
400 "silence detection: db error fetching task — agent/model cooldown will be skipped"
401 );
402 None
403 }
404 },
405 Ok(None) => {
406 tracing::debug!(
407 task_id,
408 repo,
409 operation = "resolve_task_id",
410 "silence detection: task not found in store (may have been cleaned up)"
411 );
412 None
413 }
414 Err(e) => {
415 tracing::warn!(
416 task_id,
417 repo,
418 operation = "resolve_task_id",
419 error = %e,
420 "silence detection: db error resolving task id — agent/model cooldown will be skipped"
421 );
422 None
423 }
424 };
425 let agent_name = store_task
426 .as_ref()
427 .and_then(|t| t.agent.clone())
428 .unwrap_or_default();
429 let model_name = store_task
430 .as_ref()
431 .and_then(|t| t.model.clone())
432 .unwrap_or_default();
433
434 tracing::warn!(
435 task_id,
436 agent = %agent_name,
437 model = %model_name,
438 silence_age_secs,
439 grace_secs = config.silence_grace_period,
440 cooldown_secs = config.silence_cooldown,
441 "agent silent since session start — killing session, cooling down model + agent, failing over"
442 );
443
444 if let Err(e) = tmux.kill_session(&session_name).await {
446 tracing::debug!(
447 task_id,
448 ?e,
449 "kill_session failed for silent agent (may already be gone)"
450 );
451 }
452
453 capture.unregister_session(repo, &task_id).await;
455
456 if let Some(store_id) = store_task.as_ref().map(|t| t.id) {
458 finalize_open_runs_for_recovery(store, store_id, "silence detection: session killed")
459 .await;
460 }
461
462 let mut extended_note = String::new();
463
464 if !agent_name.is_empty() && !model_name.is_empty() {
466 set_model_cooldown(
467 &agent_name,
468 &model_name,
469 config.silence_cooldown,
470 "silence_detected",
471 )
472 .await;
473 if let Some(result) = record_silence_detection(&agent_name, &model_name).await {
474 if result.extended_cooldown_applied {
475 extended_note = format!(
476 " ({} silences in 24h -> extended cooldown {}s)",
477 result.count, SILENCE_EXTENDED_COOLDOWN_SECS
478 );
479 }
480 }
481 }
482
483 if !agent_name.is_empty() {
487 set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
488 }
489
490 let task_eid = ExternalId(task_id.clone());
495
496 let available: Vec<String> = crate::engine::configured_agents()
498 .into_iter()
499 .filter(|a| crate::cmd_cache::command_exists(a))
500 .collect();
501 let chain = crate::engine::runner::response::get_reroute_chain(
502 &task_id,
503 &Some(Arc::clone(store)),
504 repo,
505 )
506 .await;
507 let chain = crate::engine::runner::response::update_reroute_chain(
508 &task_id,
509 &agent_name,
510 &chain,
511 &Some(Arc::clone(store)),
512 repo,
513 )
514 .await;
515
516 let (next_status, next_agent) = if let Some(fallback) =
517 crate::engine::runner::response::pick_fallback_agent(&agent_name, &chain, &available)
518 {
519 tracing::info!(
520 task_id,
521 from = %agent_name,
522 to = %fallback,
523 "silence detection: failover to different agent, setting routed"
524 );
525 (Status::Routed, Some(fallback))
526 } else {
527 tracing::warn!(
528 task_id,
529 "silence detection: no fallback agents available, resetting to new for re-routing"
530 );
531 (Status::New, None)
532 };
533
534 if use_backend {
535 if let Some(ref st) = store_task {
536 let stale_labels: Vec<String> = st
538 .labels
539 .iter()
540 .filter(|l| {
541 l.starts_with("agent:")
542 || l.starts_with("complexity:")
543 || l.starts_with("model:")
544 })
545 .cloned()
546 .collect();
547 if !stale_labels.is_empty() {
548 let backend_clone = Arc::clone(backend);
549 let task_eid_clone = task_eid.clone();
550 tokio::spawn(async move {
551 for label in &stale_labels {
552 if let Err(e) = backend_clone.remove_label(&task_eid_clone, label).await
553 {
554 tracing::warn!(task_id = task_eid_clone.0, label, error = %e, "failed to remove label during silence detection re-route");
555 }
556 }
557 });
558 }
559 }
560 }
561
562 if let Some(store_id) = store_task.as_ref().map(|t| t.id) {
563 if let Some(ref fallback) = next_agent {
564 let _ = store_set_by_id(
568 &Some(store),
569 store_id,
570 &[
571 ("agent", serde_json::json!(fallback)),
572 ("model", serde_json::json!("")),
573 (
574 "last_error",
575 serde_json::json!(format!(
576 "silence detected after {}s, failing over to {}",
577 config.silence_grace_period, fallback
578 )),
579 ),
580 ],
581 )
582 .await;
583 } else {
584 let _ = store_set_by_id(
585 &Some(store),
586 store_id,
587 &[
588 ("agent", serde_json::Value::Null),
589 ("model", serde_json::Value::Null),
590 (
591 "last_error",
592 serde_json::json!(format!(
593 "silence detected after {}s, no fallback agents available",
594 config.silence_grace_period
595 )),
596 ),
597 ],
598 )
599 .await;
600 }
601 }
602
603 if let Err(e) = task_manager
604 .update_task_status(&task_eid, next_status)
605 .await
606 {
607 tracing::warn!(task_id, ?e, "failed to update silent task status");
608 continue;
609 }
610
611 let details = serde_json::json!({
613 "failure_reason": "agent_silence",
614 "silence_duration_secs": config.silence_grace_period,
615 "cooldown_applied": !agent_name.is_empty() && !model_name.is_empty(),
616 "fallback_available": next_agent.is_some(),
617 });
618 crate::store::store_log_activity(
619 &Some(Arc::clone(store)),
620 repo,
621 &task_id,
622 "rerouted",
623 Some("in_progress"),
624 Some(next_status.as_str()),
625 next_agent.as_deref(),
626 None,
627 Some(&details),
628 )
629 .await;
630
631 let action = if let Some(ref fallback) = next_agent {
633 format!("failing over to {fallback}")
634 } else {
635 "marking needs_review (no fallback agents)".to_string()
636 };
637 let comment = format!(
638 "[{}] agent silent for {}s since session start — killed session, cooled down model `{}:{}` for {}s{}, agent `{}` for {}s, {}{}",
639 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
640 config.silence_grace_period,
641 agent_name,
642 model_name,
643 config.silence_cooldown,
644 extended_note,
645 agent_name,
646 SILENCE_AGENT_COOLDOWN_SECS,
647 action,
648 crate::engine::orch_footer(),
649 );
650 if use_backend {
651 let backend_clone = Arc::clone(backend);
653 let task_eid_clone = task_eid.clone();
654 tokio::spawn(async move {
655 if let Err(e) = backend_clone.post_comment(&task_eid_clone, &comment).await {
656 tracing::warn!(
657 task_id = task_eid_clone.0,
658 ?e,
659 "failed to post silence detection comment"
660 );
661 }
662 });
663 }
664 }
665
666 Ok(())
667}
668
669fn should_use_backend(task_id: &str) -> bool {
670 !is_internal_id(task_id)
671}
672
673#[allow(clippy::too_many_arguments)]
678pub(crate) async fn tick_recover_stuck_tasks(
679 backend: &Arc<dyn ExternalBackend>,
680 tmux: &Arc<TmuxManager>,
681 repo: &str,
682 task_manager: &Arc<TaskManager>,
683 config: &EngineConfig,
684 store: &Arc<TaskStore>,
685 session_map: &std::collections::HashMap<String, bool>,
686 router: &Router,
687 dispatching: &Arc<DashMap<String, String>>,
688 auto_merge_in_flight: &Arc<DashSet<String>>,
689) -> anyhow::Result<()> {
690 let _span = tracing::info_span!("engine.tick.phase2.stuck_tasks").entered();
691
692 let in_progress = match task_manager
693 .list_external_by_status(Status::InProgress)
694 .await
695 {
696 Ok(tasks) => tasks,
697 Err(e) => {
698 tracing::warn!(?e, "failed to list in_progress tasks for stuck recovery");
699 vec![]
700 }
701 };
702 for task in &in_progress {
703 let Some(timing) = stuck_task_timing_from_map(
704 tmux,
705 repo,
706 &task.id.0,
707 &task.updated_at,
708 config,
709 "cannot parse updated_at, skipping stuck-task check",
710 session_map,
711 ) else {
712 continue;
713 };
714
715 if !timing.has_session {
722 let dispatch_key = format!("{repo}/{}", task.id.0);
723 if dispatching.contains_key(&dispatch_key) {
724 tracing::info!(
732 task_id = task.id.0,
733 dispatch_key,
734 age_mins = timing.age.num_minutes(),
735 "skipping stuck-task reclaim: runner still actively processing this task"
736 );
737 continue;
738 }
739 }
740
741 if timing.has_session {
742 tracing::warn!(
743 task_id = task.id.0,
744 age_mins = timing.age.num_minutes(),
745 threshold_mins = timing.threshold / 60,
746 "recovering stuck task: timed out with active session → new"
747 );
748 } else {
749 tracing::warn!(
750 task_id = task.id.0,
751 dispatch_key = format!("{repo}/{}", task.id.0),
752 age_mins = timing.age.num_minutes(),
753 threshold_mins = timing.threshold / 60,
754 "recovering stuck task: no session found — reclaiming early → new"
755 );
756 }
757 let mut cached_store_id: Option<i64> = None;
762 if timing.has_session {
763 let store_task = match store.resolve_task_id(repo, &task.id.0).await {
764 Ok(Some(store_id)) => {
765 cached_store_id = Some(store_id);
766 match store.get(store_id).await {
767 Ok(t) => Some(t),
768 Err(e) => {
769 tracing::warn!(task_id = task.id.0, error = %e, "failed to fetch task from store for stuck-task cooldown");
770 None
771 }
772 }
773 }
774 Ok(None) => None,
775 Err(e) => {
776 tracing::warn!(task_id = task.id.0, error = %e, "failed to resolve task id for stuck-task cooldown");
777 None
778 }
779 };
780 let agent_name = store_task
781 .as_ref()
782 .and_then(|t| t.agent.clone())
783 .unwrap_or_default();
784 let model_name = store_task
785 .as_ref()
786 .and_then(|t| t.model.clone())
787 .unwrap_or_default();
788
789 if !agent_name.is_empty() && !model_name.is_empty() {
790 set_model_cooldown(
791 &agent_name,
792 &model_name,
793 config.silence_cooldown,
794 "silence_detected",
795 )
796 .await;
797 record_agent_failure_with_message(
798 &agent_name,
799 &format!(
800 "stuck with active session after {}m",
801 timing.age.num_minutes()
802 ),
803 )
804 .await;
805 }
806 if !agent_name.is_empty() {
807 set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
808 }
809 tracing::warn!(
810 task_id = task.id.0,
811 agent = %agent_name,
812 model = %model_name,
813 cooldown_secs = config.silence_cooldown,
814 "stuck-task cooldown applied — router will avoid this agent/model on next attempt"
815 );
816 }
817
818 {
821 let stale_labels: Vec<String> = task
822 .labels
823 .iter()
824 .filter(|l| l.starts_with("agent:") || l.starts_with("model:"))
825 .cloned()
826 .collect();
827 if !stale_labels.is_empty() {
828 let backend_clone = Arc::clone(backend);
829 let task_id_clone = task.id.clone();
830 tokio::spawn(async move {
831 for label in &stale_labels {
832 if let Err(e) = backend_clone.remove_label(&task_id_clone, label).await {
833 tracing::warn!(task_id = task_id_clone.0, label, error = %e, "failed to remove stale routing label during stuck-task recovery");
834 }
835 }
836 });
837 }
838 }
839 let resolved_store_id = match cached_store_id {
840 Some(id) => Some(id),
841 None => match store.resolve_task_id(repo, &task.id.0).await {
842 Ok(Some(id)) => Some(id),
843 Ok(None) => {
844 tracing::warn!(
845 task_id = %task.id.0,
846 repo,
847 "resolve_task_id returned None during stuck-task recovery"
848 );
849 None
850 }
851 Err(e) => {
852 tracing::error!(
853 task_id = %task.id.0,
854 repo,
855 error = %e,
856 "resolve_task_id failed during stuck-task recovery"
857 );
858 None
859 }
860 },
861 };
862 if let Some(store_id) = resolved_store_id {
863 if let Err(e) = store_set_by_id(
864 &Some(store),
865 store_id,
866 &[
867 ("agent", serde_json::Value::Null),
868 ("model", serde_json::Value::Null),
869 ("route_attempts", serde_json::json!(0)),
870 ],
871 )
872 .await
873 {
874 tracing::warn!(
875 task_id = task.id.0,
876 ?e,
877 "failed to clear routing fields for stuck task — will retry next tick"
878 );
879 continue;
880 }
881 finalize_open_runs_for_recovery(
882 store,
883 store_id,
884 if timing.has_session {
885 "stuck-task recovery: active session killed"
886 } else {
887 "stuck-task recovery: no session found"
888 },
889 )
890 .await;
891 }
892 if let Err(e) = task_manager.update_task_status(&task.id, Status::New).await {
893 tracing::warn!(task_id = task.id.0, ?e, "failed to reset stuck task status");
894 continue;
895 }
896
897 let details = serde_json::json!({
899 "failure_reason": if timing.has_session { "stuck_with_session" } else { "stuck_no_session" },
900 "age_minutes": timing.age.num_minutes(),
901 "had_session": timing.has_session,
902 });
903 crate::store::store_log_activity(
904 &Some(Arc::clone(store)),
905 repo,
906 &task.id.0,
907 "rerouted",
908 Some("in_progress"),
909 Some("new"),
910 None::<&str>,
911 None::<&str>,
912 Some(&details),
913 )
914 .await;
915
916 let reason = if timing.has_session {
917 format!(
918 "timed out after {}m with active session (cleared agent for re-routing)",
919 timing.age.num_minutes()
920 )
921 } else {
922 format!(
923 "no session found — reclaiming early after {}m (cleared agent for re-routing)",
924 timing.age.num_minutes()
925 )
926 };
927 {
929 let backend_clone = Arc::clone(backend);
930 let task_id_clone = task.id.clone();
931 let body = format!(
932 "[{}] recovered: stuck in_progress — {}{}",
933 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
934 reason,
935 crate::engine::orch_footer()
936 );
937 tokio::spawn(async move {
938 if let Err(e) = backend_clone.post_comment(&task_id_clone, &body).await {
939 tracing::warn!(
940 task_id = task_id_clone.0,
941 ?e,
942 "failed to post stuck-task recovery comment"
943 );
944 }
945 });
946 }
947 }
948
949 let mut routed = match task_manager.list_external_by_status(Status::Routed).await {
950 Ok(tasks) => tasks,
951 Err(e) => {
952 tracing::warn!(?e, "failed to list routed tasks for stuck recovery");
953 vec![]
954 }
955 };
956 match task_manager
957 .list_internal_by_status(crate::store::TaskStatus::Routed)
958 .await
959 {
960 Ok(tasks) => routed.extend(tasks),
961 Err(e) => tracing::warn!(
962 ?e,
963 "failed to list internal routed tasks for stuck recovery"
964 ),
965 }
966 for task in &routed {
967 let Some(timing) = stuck_task_timing_from_map(
968 tmux,
969 repo,
970 &task.id.0,
971 &task.updated_at,
972 config,
973 "cannot parse updated_at, skipping routed stuck-task check",
974 session_map,
975 ) else {
976 continue;
977 };
978
979 if !timing.has_session {
980 continue;
981 }
982
983 let session_name = tmux.session_name(repo, &task.id.0);
984 if let Err(e) = tmux.kill_session(&session_name).await {
985 tracing::warn!(
986 task_id = task.id.0,
987 session = %session_name,
988 error = %e,
989 "failed to kill stale tmux session blocking routed task"
990 );
991 continue;
992 }
993
994 recover_routed_blocked_dispatch(backend, store, task_manager, repo, task, &timing).await;
995 }
996
997 let all_external_in_progress = match store
1001 .list_all_by_status_global(crate::store::TaskStatus::InProgress)
1002 .await
1003 {
1004 Ok(tasks) => tasks,
1005 Err(e) => {
1006 tracing::warn!(
1007 ?e,
1008 "failed to list global in_progress tasks for cross-repo stuck recovery"
1009 );
1010 vec![]
1011 }
1012 };
1013 for task in all_external_in_progress
1014 .iter()
1015 .filter(|t| t.origin != "internal" && t.repo != repo)
1016 {
1017 let external_id = task
1018 .external_id
1019 .clone()
1020 .unwrap_or_else(|| task.id.to_string());
1021 let Some(timing) = stuck_task_timing_from_map(
1022 tmux,
1023 &task.repo,
1024 &external_id,
1025 &task.updated_at,
1026 config,
1027 "cannot parse updated_at, skipping cross-repo stuck-task check",
1028 session_map,
1029 ) else {
1030 continue;
1031 };
1032
1033 if !timing.has_session {
1037 let dispatch_key = format!("{}/{external_id}", task.repo);
1038 if dispatching.contains_key(&dispatch_key) {
1039 tracing::info!(
1043 task_id = task.id,
1044 repo = %task.repo,
1045 dispatch_key,
1046 age_mins = timing.age.num_minutes(),
1047 "skipping cross-repo stuck-task reclaim: runner still actively processing this task"
1048 );
1049 continue;
1050 }
1051 }
1052
1053 if timing.has_session {
1054 tracing::warn!(
1055 task_id = task.id,
1056 repo = %task.repo,
1057 age_mins = timing.age.num_minutes(),
1058 threshold_mins = timing.threshold / 60,
1059 "recovering cross-repo stuck task: timed out with active session → new"
1060 );
1061 } else {
1062 tracing::warn!(
1063 task_id = task.id,
1064 repo = %task.repo,
1065 dispatch_key = format!("{}/{external_id}", task.repo),
1066 age_mins = timing.age.num_minutes(),
1067 threshold_mins = timing.threshold / 60,
1068 "recovering cross-repo stuck task: no session found — reclaiming early → new"
1069 );
1070 }
1071
1072 if let Err(e) = store_set_by_id(
1073 &Some(store),
1074 task.id,
1075 &[
1076 ("agent", serde_json::Value::Null),
1077 ("model", serde_json::Value::Null),
1078 ("route_attempts", serde_json::json!(0)),
1079 ],
1080 )
1081 .await
1082 {
1083 tracing::warn!(
1084 task_id = task.id,
1085 repo = %task.repo,
1086 ?e,
1087 "failed to clear routing fields for cross-repo stuck task — will retry next tick"
1088 );
1089 continue;
1090 }
1091
1092 finalize_open_runs_for_recovery(
1093 store,
1094 task.id,
1095 if timing.has_session {
1096 "stuck-task recovery: cross-repo active session killed"
1097 } else {
1098 "stuck-task recovery: cross-repo no session found"
1099 },
1100 )
1101 .await;
1102
1103 if let Err(e) = store
1104 .update_status(task.id, crate::store::TaskStatus::New)
1105 .await
1106 {
1107 tracing::warn!(
1108 task_id = task.id,
1109 repo = %task.repo,
1110 ?e,
1111 "failed to reset cross-repo stuck task status"
1112 );
1113 continue;
1114 }
1115
1116 let details = serde_json::json!({
1117 "failure_reason": if timing.has_session { "stuck_with_session" } else { "stuck_no_session" },
1118 "age_minutes": timing.age.num_minutes(),
1119 "had_session": timing.has_session,
1120 "scope": "cross_repo_global_sweep",
1121 });
1122 crate::store::store_log_activity(
1123 &Some(Arc::clone(store)),
1124 &task.repo,
1125 &external_id,
1126 "rerouted",
1127 Some("in_progress"),
1128 Some("new"),
1129 None::<&str>,
1130 None::<&str>,
1131 Some(&details),
1132 )
1133 .await;
1134 }
1135
1136 use crate::store::TaskStatus as DbStatus;
1139 let internal_in_progress = match task_manager
1140 .list_internal_by_status(DbStatus::InProgress)
1141 .await
1142 {
1143 Ok(tasks) => tasks,
1144 Err(e) => {
1145 tracing::warn!(
1146 ?e,
1147 "failed to list internal in_progress tasks for stuck recovery"
1148 );
1149 vec![]
1150 }
1151 };
1152 for task in &internal_in_progress {
1153 let task_id = task.id.0.clone();
1154 let Some(timing) = stuck_task_timing_from_map(
1155 tmux,
1156 repo,
1157 &task_id,
1158 &task.updated_at,
1159 config,
1160 "cannot parse updated_at, skipping stuck internal-task check",
1161 session_map,
1162 ) else {
1163 continue;
1164 };
1165
1166 if !timing.has_session {
1172 let dispatch_key = format!("{repo}/{task_id}");
1173 if dispatching.contains_key(&dispatch_key) {
1174 tracing::info!(
1184 task_id,
1185 dispatch_key,
1186 age_mins = timing.age.num_minutes(),
1187 "skipping stuck-task reclaim: runner still actively processing this task"
1188 );
1189 continue;
1190 }
1191 }
1192
1193 if timing.has_session {
1194 tracing::warn!(
1195 task_id,
1196 age_mins = timing.age.num_minutes(),
1197 threshold_mins = timing.threshold / 60,
1198 "recovering stuck task: timed out with active session → new"
1199 );
1200 } else {
1201 tracing::warn!(
1202 task_id,
1203 dispatch_key = format!("{repo}/{task_id}"),
1204 age_mins = timing.age.num_minutes(),
1205 threshold_mins = timing.threshold / 60,
1206 "recovering stuck task: no session found — reclaiming early → new"
1207 );
1208 }
1209 let mut cached_store_id: Option<i64> = None;
1213 if timing.has_session {
1214 let store_task = match store.resolve_task_id(repo, &task_id).await {
1215 Ok(Some(store_id)) => {
1216 cached_store_id = Some(store_id);
1217 match store.get(store_id).await {
1218 Ok(t) => Some(t),
1219 Err(e) => {
1220 tracing::warn!(task_id, error = %e, "failed to fetch task from store for stuck internal-task cooldown");
1221 None
1222 }
1223 }
1224 }
1225 Ok(None) => None,
1226 Err(e) => {
1227 tracing::warn!(task_id, error = %e, "failed to resolve task id for stuck internal-task cooldown");
1228 None
1229 }
1230 };
1231 let agent_name = store_task
1232 .as_ref()
1233 .and_then(|t| t.agent.clone())
1234 .unwrap_or_default();
1235 let model_name = store_task
1236 .as_ref()
1237 .and_then(|t| t.model.clone())
1238 .unwrap_or_default();
1239
1240 if !agent_name.is_empty() && !model_name.is_empty() {
1241 set_model_cooldown(
1242 &agent_name,
1243 &model_name,
1244 config.silence_cooldown,
1245 "silence_detected",
1246 )
1247 .await;
1248 record_agent_failure_with_message(
1249 &agent_name,
1250 &format!(
1251 "internal task stuck with active session after {}m",
1252 timing.age.num_minutes()
1253 ),
1254 )
1255 .await;
1256 }
1257 if !agent_name.is_empty() {
1258 set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
1259 }
1260 tracing::warn!(
1261 task_id,
1262 agent = %agent_name,
1263 model = %model_name,
1264 cooldown_secs = config.silence_cooldown,
1265 "internal stuck-task cooldown applied — router will avoid this agent/model on next attempt"
1266 );
1267 }
1268 let resolved_store_id = match cached_store_id {
1271 Some(id) => Some(id),
1272 None => match store.resolve_task_id(repo, &task_id).await {
1273 Ok(Some(id)) => Some(id),
1274 Ok(None) => {
1275 tracing::warn!(
1276 task_id = %task_id,
1277 repo,
1278 "resolve_task_id returned None during stuck internal-task recovery"
1279 );
1280 None
1281 }
1282 Err(e) => {
1283 tracing::error!(
1284 task_id = %task_id,
1285 repo,
1286 error = %e,
1287 "resolve_task_id failed during stuck internal-task recovery"
1288 );
1289 None
1290 }
1291 },
1292 };
1293 if let Some(store_id) = resolved_store_id {
1294 if let Err(e) = store_set_by_id(
1295 &Some(store),
1296 store_id,
1297 &[
1298 ("agent", serde_json::Value::Null),
1299 ("model", serde_json::Value::Null),
1300 ("route_attempts", serde_json::json!(0)),
1301 ],
1302 )
1303 .await
1304 {
1305 tracing::warn!(
1306 task_id,
1307 ?e,
1308 "failed to clear routing fields for stuck internal task — will retry next tick"
1309 );
1310 continue;
1311 }
1312 finalize_open_runs_for_recovery(
1313 store,
1314 store_id,
1315 if timing.has_session {
1316 "stuck-task recovery: internal active session killed"
1317 } else {
1318 "stuck-task recovery: internal no session found"
1319 },
1320 )
1321 .await;
1322 }
1323 if let Err(e) = task_manager
1324 .update_task_status(&ExternalId(task_id.clone()), Status::New)
1325 .await
1326 {
1327 tracing::warn!(task_id, ?e, "failed to reset stuck internal task status");
1328 }
1329 }
1330
1331 let in_review = match task_manager.list_external_by_status(Status::InReview).await {
1335 Ok(tasks) => tasks,
1336 Err(e) => {
1337 tracing::warn!(?e, "failed to list in_review tasks for stuck recovery");
1338 vec![]
1339 }
1340 };
1341 let store_in_review = match store
1345 .list_by_status(repo, crate::store::TaskStatus::InReview)
1346 .await
1347 {
1348 Ok(v) => v,
1349 Err(e) => {
1350 tracing::warn!(
1351 ?e,
1352 "failed to list store in_review tasks for review_session_expected prefetch"
1353 );
1354 vec![]
1355 }
1356 };
1357 let review_expected_set: std::collections::HashSet<String> = store_in_review
1358 .into_iter()
1359 .filter(|t| t.review_session_expected)
1360 .filter_map(|t| t.external_id)
1361 .collect();
1362 for task in &in_review {
1363 if !review_expected_set.contains(&task.id.0) {
1364 tracing::debug!(
1365 task_id = task.id.0,
1366 "in_review task is waiting on PR review, skipping stuck-session recovery"
1367 );
1368 continue;
1369 }
1370
1371 let review_task_id = format!("{}-review", task.id.0);
1372 let in_review_config = crate::engine::EngineConfig {
1380 no_session_stuck_timeout: config.in_review_no_session_stuck_timeout,
1381 ..config.clone()
1382 };
1383 let Some(timing) = stuck_task_timing_from_map(
1384 tmux,
1385 repo,
1386 &review_task_id,
1387 &task.updated_at,
1388 &in_review_config,
1389 "cannot parse updated_at, skipping stuck in_review check",
1390 session_map,
1391 ) else {
1392 continue;
1393 };
1394
1395 tracing::warn!(
1396 task_id = task.id.0,
1397 age_mins = timing.age.num_minutes(),
1398 threshold_mins = timing.threshold / 60,
1399 "recovering stuck in_review task: no session found — resetting to needs_review"
1400 );
1401 if let Err(e) = task_manager
1402 .update_task_status(&task.id, Status::NeedsReview)
1403 .await
1404 {
1405 tracing::warn!(
1406 task_id = task.id.0,
1407 ?e,
1408 "failed to reset stuck in_review task status"
1409 );
1410 continue;
1411 } else {
1412 set_review_session_expected(store, repo, &task.id.0, false).await;
1413 }
1414
1415 if let Ok(Some(store_id)) = store.resolve_task_id(repo, &task.id.0).await {
1417 finalize_open_runs_for_recovery(
1418 store,
1419 store_id,
1420 "stuck-task recovery: in_review session killed",
1421 )
1422 .await;
1423 }
1424
1425 let details = serde_json::json!({
1427 "failure_reason": "stuck_in_review",
1428 "age_minutes": timing.age.num_minutes(),
1429 "had_session": timing.has_session,
1430 });
1431 crate::store::store_log_activity(
1432 &Some(Arc::clone(store)),
1433 repo,
1434 &task.id.0,
1435 "rerouted",
1436 Some("in_review"),
1437 Some("needs_review"),
1438 None::<&str>,
1439 None::<&str>,
1440 Some(&details),
1441 )
1442 .await;
1443 if let Err(e) = backend
1444 .post_comment(
1445 &task.id,
1446 &format!(
1447 "[{}] recovered: stuck in_review — no session found after {}m, resetting to needs_review{}",
1448 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
1449 timing.age.num_minutes(),
1450 crate::engine::orch_footer()
1451 ),
1452 )
1453 .await
1454 {
1455 tracing::warn!(
1456 task_id = task.id.0,
1457 ?e,
1458 "failed to post stuck in_review recovery comment"
1459 );
1460 }
1461 }
1462
1463 let internal_in_review = match task_manager
1465 .list_internal_by_status(DbStatus::InReview)
1466 .await
1467 {
1468 Ok(tasks) => tasks,
1469 Err(e) => {
1470 tracing::warn!(
1471 ?e,
1472 "failed to list internal in_review tasks for stuck recovery"
1473 );
1474 vec![]
1475 }
1476 };
1477 for task in &internal_in_review {
1478 let task_id = task.id.0.clone();
1479 if !review_expected_set.contains(&task_id) {
1480 tracing::debug!(
1481 task_id,
1482 "internal in_review task is waiting on PR review, skipping stuck-session recovery"
1483 );
1484 continue;
1485 }
1486
1487 let review_task_id = format!("{}-review", task_id);
1488 let in_review_config = crate::engine::EngineConfig {
1489 no_session_stuck_timeout: config.in_review_no_session_stuck_timeout,
1490 ..config.clone()
1491 };
1492 let Some(timing) = stuck_task_timing_from_map(
1493 tmux,
1494 repo,
1495 &review_task_id,
1496 &task.updated_at,
1497 &in_review_config,
1498 "cannot parse updated_at, skipping stuck internal in_review check",
1499 session_map,
1500 ) else {
1501 continue;
1502 };
1503
1504 tracing::warn!(
1505 task_id,
1506 age_mins = timing.age.num_minutes(),
1507 threshold_mins = timing.threshold / 60,
1508 "recovering stuck internal in_review task: no session found — resetting to needs_review"
1509 );
1510 if let Err(e) = task_manager
1511 .update_task_status(&ExternalId(task_id.clone()), Status::NeedsReview)
1512 .await
1513 {
1514 tracing::warn!(
1515 task_id,
1516 ?e,
1517 "failed to reset stuck internal in_review task status"
1518 );
1519 } else {
1520 set_review_session_expected(store, repo, &task_id, false).await;
1521 }
1522
1523 if let Ok(Some(store_id)) = store.resolve_task_id(repo, &task_id).await {
1525 finalize_open_runs_for_recovery(
1526 store,
1527 store_id,
1528 "stuck-task recovery: internal in_review session killed",
1529 )
1530 .await;
1531 }
1532 }
1533
1534 if let Err(e) = crate::engine::sync::auto_unblock_ci_failure_blocked_tasks_global(
1539 task_manager,
1540 store,
1541 auto_merge_in_flight,
1542 )
1543 .await
1544 {
1545 tracing::warn!(err = %e, "global CI-failure blocked sweep failed");
1546 }
1547
1548 if let Err(e) = crate::engine::sync::refire_and_escalate_stale_needs_review_global(
1555 task_manager,
1556 store,
1557 router,
1558 dispatching,
1559 )
1560 .await
1561 {
1562 tracing::warn!(err = %e, "global NeedsReview refire/escalation sweep failed");
1563 }
1564
1565 if let Err(e) = crate::engine::sync::auto_recover_rebroadcast_blocked_tasks_global(
1573 router,
1574 task_manager,
1575 store,
1576 )
1577 .await
1578 {
1579 tracing::warn!(err = %e, "global rebroadcast-blocked recovery sweep failed");
1580 }
1581
1582 Ok(())
1583}
1584
1585pub(crate) async fn tick_route_tasks(
1587 backend: &Arc<dyn ExternalBackend>,
1588 task_manager: &Arc<TaskManager>,
1589 router: &mut Router,
1590 store: &Arc<TaskStore>,
1591 repo: &str,
1592 routing_quota: &mut RoutingTickQuota,
1593) -> anyhow::Result<()> {
1594 let _span = tracing::info_span!("engine.tick.phase3a.route").entered();
1595 if let Err(err) = crate::engine::dependencies::apply_claim_gates(store).await {
1596 tracing::warn!(err = %err, "dependency claim gate failed");
1597 }
1598 if routing_quota.is_exhausted() {
1599 tracing::debug!("routing quota exhausted for this tick — skipping repo routing phase");
1600 return Ok(());
1601 }
1602
1603 if is_github_circuit_open() {
1607 let remaining = github_circuit_remaining_secs();
1608 tracing::info!(
1609 remaining_secs = remaining,
1610 "GitHub 5xx circuit breaker open — skipping routing phase"
1611 );
1612 return Ok(());
1613 }
1614 let new_tasks = task_manager.list_routable().await?;
1615 let routable: Vec<&ExternalTask> = new_tasks
1616 .iter()
1617 .filter(|t| !skips_agent(&t.labels))
1618 .collect();
1619
1620 if !routable.is_empty() {
1624 let start = std::time::Instant::now();
1625 router.refresh_health(store).await;
1626 tracing::debug!(
1627 duration_ms = start.elapsed().as_millis(),
1628 "router.refresh_health completed"
1629 );
1630 }
1631
1632 for task in routable.into_iter().take(routing_quota.remaining()) {
1634 if !routing_quota.consume_one() {
1635 break;
1636 }
1637 let _task_span = tracing::info_span!("engine.route", task_id = %task.id.0).entered();
1638
1639 let task_start = Instant::now();
1640 match router.route(task, store, repo).await {
1641 Ok(result) => {
1642 tracing::debug!(task_id = %task.id.0, duration_ms = task_start.elapsed().as_millis(), "route completed");
1643 if let Err(e) = router
1645 .store_route_result(&task.id.0, &result, store, repo)
1646 .await
1647 {
1648 tracing::warn!(
1649 task_id = task.id.0,
1650 ?e,
1651 "failed to store route result; skipping Routed transition"
1652 );
1653 continue;
1654 }
1655
1656 if is_internal_id(&task.id.0) {
1657 if let Err(e) = task_manager
1659 .update_task_status(&task.id, Status::Routed)
1660 .await
1661 {
1662 tracing::warn!(
1663 task_id = task.id.0,
1664 ?e,
1665 "failed to set internal status:routed"
1666 );
1667 continue;
1668 }
1669 } else {
1670 let backend_clone = Arc::clone(backend);
1674 let task_id_clone = task.id.clone();
1675 let task_labels: Vec<String> = task.labels.clone();
1676 let new_labels = {
1677 let mut labels = vec![
1678 format!("agent:{}", result.agent),
1679 format!("complexity:{}", result.complexity),
1680 ];
1681 if let Some(ref model) = result.model {
1682 labels.push(format!("model:{model}"));
1683 }
1684 labels
1685 };
1686 tokio::spawn(async move {
1687 for label in &task_labels {
1689 if label.starts_with("agent:")
1690 || label.starts_with("complexity:")
1691 || label.starts_with("model:")
1692 {
1693 if let Err(e) =
1694 backend_clone.remove_label(&task_id_clone, label).await
1695 {
1696 tracing::warn!(task_id = task_id_clone.0, label, error = %e, "failed to remove stale routing label during re-route");
1697 }
1698 }
1699 }
1700 if let Err(e) = backend_clone.set_labels(&task_id_clone, &new_labels).await
1702 {
1703 tracing::warn!(
1704 task_id = task_id_clone.0,
1705 ?e,
1706 "failed to set routing labels"
1707 );
1708 }
1709 });
1710
1711 if let Err(e) = task_manager
1713 .update_task_status(&task.id, Status::Routed)
1714 .await
1715 {
1716 tracing::warn!(task_id = task.id.0, ?e, "failed to set status:routed");
1717 continue;
1718 }
1719 }
1720
1721 match store.ensure_external_task(repo, task).await {
1723 Ok(store_id) => {
1724 let profile_json =
1725 serde_json::to_string(&result.profile).unwrap_or_default();
1726 let skills_json =
1727 serde_json::to_string(&result.selected_skills).unwrap_or_default();
1728 if let Err(e) = store
1729 .store_route(&crate::store::StoreRoute {
1730 id: store_id,
1731 agent: &result.agent,
1732 model: result.model.as_deref(),
1733 complexity: &result.complexity,
1734 estimate: result.estimate,
1735 reason: &result.reason,
1736 profile: &profile_json,
1737 skills: &skills_json,
1738 })
1739 .await
1740 {
1741 tracing::debug!(
1742 task_id = task.id.0,
1743 ?e,
1744 "dual-write: store_route failed"
1745 );
1746 }
1747 if let Err(e) = store
1748 .update_status(store_id, crate::store::TaskStatus::Routed)
1749 .await
1750 {
1751 tracing::debug!(
1752 task_id = task.id.0,
1753 ?e,
1754 "dual-write: update_status failed"
1755 );
1756 }
1757 if result.estimate > 0 {
1761 let backend_clone = Arc::clone(backend);
1762 let task_id = task.id.clone();
1763 let estimate = result.estimate;
1764 tokio::spawn(async move {
1765 if let Err(e) = backend_clone
1766 .sync_estimate_to_project(&task_id, estimate)
1767 .await
1768 {
1769 tracing::debug!(
1770 task_id = task_id.0,
1771 estimate,
1772 err = %e,
1773 "dual-write: estimate project sync failed"
1774 );
1775 }
1776 });
1777 }
1778 }
1779 Err(e) => {
1780 tracing::debug!(
1781 task_id = task.id.0,
1782 ?e,
1783 "dual-write: ensure_external_task failed"
1784 );
1785 }
1786 }
1787
1788 if let Some(ref warning) = result.warning {
1789 tracing::warn!(task_id = task.id.0, warning, "routing sanity warning");
1790 }
1791
1792 tracing::info!(
1793 task_id = task.id.0,
1794 agent = %result.agent,
1795 complexity = %result.complexity,
1796 reason = %result.reason,
1797 "task routed"
1798 );
1799 }
1800 Err(e) => {
1801 if let Some(cooled) = e.downcast_ref::<AllAgentsCooledError>() {
1802 tracing::error!(
1803 task_id = %task.id.0,
1804 scope = %cooled.scope(),
1805 remaining_secs = ?cooled.remaining_secs(),
1806 "ALL AGENTS COOLED — no agent available to route this task. \
1807 Task stays in 'new' and will be retried on every tick. \
1808 Check `orch cooldown list` to see what is blocking dispatch."
1809 );
1810 continue;
1811 }
1812 if let Some(waiting) = e.downcast_ref::<crate::store::WaitingOnDependency>() {
1813 tracing::info!(
1814 task_id = %task.id.0,
1815 blockers = %waiting.blockers.join(", "),
1816 "task is waiting on a dependency"
1817 );
1818 continue;
1819 }
1820 tracing::error!(task_id = task.id.0, ?e, "routing failed, skipping task");
1821 }
1822 }
1823 }
1824 Ok(())
1825}
1826
1827pub(crate) async fn route_new_tasks_global(
1843 router: &mut Router,
1844 store: &Arc<TaskStore>,
1845 active_repos: &std::collections::HashSet<String>,
1846 routing_quota: &mut RoutingTickQuota,
1847) -> anyhow::Result<()> {
1848 use crate::store::{StoreRoute, TaskStatus};
1849
1850 if routing_quota.is_exhausted() {
1851 tracing::debug!("routing quota exhausted for this tick — skipping global routing sweep");
1852 return Ok(());
1853 }
1854
1855 let all_new = store.list_all_by_status_global(TaskStatus::New).await?;
1856 let orphaned: Vec<&crate::store::Task> = all_new
1857 .iter()
1858 .filter(|t| !active_repos.contains(&t.repo))
1859 .filter(|t| !skips_agent(&t.labels))
1860 .collect();
1861
1862 if orphaned.is_empty() {
1863 return Ok(());
1864 }
1865
1866 tracing::info!(
1867 count = orphaned.len(),
1868 "global routing sweep: found new tasks from inactive repos"
1869 );
1870
1871 router.refresh_health(store).await;
1872
1873 for task in orphaned.into_iter().take(routing_quota.remaining()) {
1874 if !routing_quota.consume_one() {
1875 break;
1876 }
1877 let ext_task = crate::engine::tasks::store_task_to_external(task);
1878 let task_start = Instant::now();
1879 match router.route(&ext_task, store, &task.repo).await {
1880 Ok(result) => {
1881 tracing::debug!(
1882 task_id = %ext_task.id.0,
1883 repo = %task.repo,
1884 duration_ms = task_start.elapsed().as_millis(),
1885 "global sweep: route completed"
1886 );
1887 let profile_json = serde_json::to_string(&result.profile).unwrap_or_default();
1888 let skills_json =
1889 serde_json::to_string(&result.selected_skills).unwrap_or_default();
1890 if let Err(e) = store
1891 .store_route(&StoreRoute {
1892 id: task.id,
1893 agent: &result.agent,
1894 model: result.model.as_deref(),
1895 complexity: &result.complexity,
1896 estimate: result.estimate,
1897 reason: &result.reason,
1898 profile: &profile_json,
1899 skills: &skills_json,
1900 })
1901 .await
1902 {
1903 tracing::warn!(
1904 task_id = %ext_task.id.0,
1905 repo = %task.repo,
1906 err = %e,
1907 "global sweep: failed to store route result; skipping status update"
1908 );
1909 continue;
1910 }
1911 if let Err(e) = store.update_status(task.id, TaskStatus::Routed).await {
1912 tracing::warn!(
1913 task_id = %ext_task.id.0,
1914 repo = %task.repo,
1915 err = %e,
1916 "global sweep: failed to set status:routed"
1917 );
1918 } else {
1919 tracing::info!(
1920 task_id = %ext_task.id.0,
1921 repo = %task.repo,
1922 agent = %result.agent,
1923 complexity = %result.complexity,
1924 "global sweep: task routed"
1925 );
1926 }
1927 if let Some(ref warning) = result.warning {
1928 tracing::warn!(task_id = %ext_task.id.0, warning, "global sweep: routing sanity warning");
1929 }
1930 }
1931 Err(e) => {
1932 if let Some(cooled) = e.downcast_ref::<AllAgentsCooledError>() {
1933 tracing::error!(
1934 task_id = %ext_task.id.0,
1935 repo = %task.repo,
1936 scope = %cooled.scope(),
1937 remaining_secs = ?cooled.remaining_secs(),
1938 "global sweep: ALL AGENTS COOLED — task stays in 'new'"
1939 );
1940 continue;
1941 }
1942 if let Some(waiting) = e.downcast_ref::<crate::store::WaitingOnDependency>() {
1943 tracing::info!(
1944 task_id = %ext_task.id.0,
1945 repo = %task.repo,
1946 blockers = %waiting.blockers.join(", "),
1947 "global sweep: task is waiting on a dependency"
1948 );
1949 continue;
1950 }
1951 tracing::error!(
1952 task_id = %ext_task.id.0,
1953 repo = %task.repo,
1954 err = ?e,
1955 "global sweep: routing failed, skipping task"
1956 );
1957 }
1958 }
1959 }
1960
1961 Ok(())
1962}
1963
1964#[derive(Debug, Clone, Copy)]
1966pub(crate) struct DispatchMode {
1967 pub(crate) is_degraded: bool,
1968 pub(crate) healthy_agents: usize,
1969 pub(crate) threshold: usize,
1970}
1971
1972pub(crate) fn dispatch_mode_from_router(router: &Router) -> DispatchMode {
1973 let threshold = crate::engine::router::config::min_healthy_agents_threshold();
1974 let healthy_agents = router.healthy_agent_count("simple");
1975 let is_degraded = router.is_degraded(threshold);
1976 DispatchMode {
1977 is_degraded,
1978 healthy_agents,
1979 threshold,
1980 }
1981}
1982
1983#[allow(clippy::too_many_arguments)]
1985#[tracing::instrument(skip_all, name = "engine.tick.phase3b.dispatch")]
1986pub(crate) async fn tick_dispatch_tasks(
1987 backend: &Arc<dyn ExternalBackend>,
1988 tmux: &Arc<TmuxManager>,
1989 repo: &str,
1990 runner: &Arc<TaskRunner>,
1991 capture: &Arc<CaptureService>,
1992 semaphore: &Arc<Semaphore>,
1993 task_manager: &Arc<TaskManager>,
1994 weight_tx: &mpsc::Sender<WeightSignal>,
1995 dispatch_mode: DispatchMode,
1996 dispatching: &Arc<DashMap<String, String>>,
1997 store: &Arc<TaskStore>,
1998 session_map: &std::collections::HashMap<String, bool>,
1999) -> anyhow::Result<()> {
2000 let mut routed_tasks = task_manager.list_external_by_status(Status::Routed).await?;
2002
2003 use crate::store::TaskStatus as DbStatus;
2005 let internal_routed = task_manager
2006 .list_internal_by_status(DbStatus::Routed)
2007 .await?;
2008 routed_tasks.extend(internal_routed);
2009
2010 let dispatchable: Vec<ExternalTask> = routed_tasks
2012 .into_iter()
2013 .filter(|t| !skips_agent(&t.labels))
2014 .collect();
2015
2016 dispatch_tasks_for_repo(
2017 backend,
2018 tmux,
2019 repo,
2020 runner,
2021 capture,
2022 semaphore,
2023 task_manager,
2024 weight_tx,
2025 dispatch_mode,
2026 dispatching,
2027 store,
2028 session_map,
2029 dispatchable,
2030 )
2031 .await
2032}
2033
2034#[allow(clippy::too_many_arguments)]
2040async fn dispatch_tasks_for_repo(
2041 backend: &Arc<dyn ExternalBackend>,
2042 tmux: &Arc<TmuxManager>,
2043 repo: &str,
2044 runner: &Arc<TaskRunner>,
2045 capture: &Arc<CaptureService>,
2046 semaphore: &Arc<Semaphore>,
2047 task_manager: &Arc<TaskManager>,
2048 weight_tx: &mpsc::Sender<WeightSignal>,
2049 dispatch_mode: DispatchMode,
2050 dispatching: &Arc<DashMap<String, String>>,
2051 store: &Arc<TaskStore>,
2052 session_map: &std::collections::HashMap<String, bool>,
2053 dispatchable: Vec<ExternalTask>,
2054) -> anyhow::Result<()> {
2055 if dispatchable.is_empty() {
2056 tracing::debug!(count = 0, "dispatchable tasks found");
2057 return Ok(());
2058 }
2059
2060 tracing::info!(count = dispatchable.len(), "dispatchable tasks found");
2061 if dispatch_mode.is_degraded {
2062 tracing::warn!(
2063 healthy_agents = dispatch_mode.healthy_agents,
2064 threshold = dispatch_mode.threshold,
2065 "degraded mode: using sequential dispatch"
2066 );
2067 }
2068
2069 let sequential_delay = if dispatch_mode.is_degraded {
2070 crate::engine::router::config::sequential_dispatch_delay_ms()
2071 } else {
2072 0
2073 };
2074
2075 for (idx, task) in dispatchable.into_iter().enumerate() {
2076 if crate::engine::dependencies::dispatch_is_waiting(store, repo, &task.id.0).await {
2077 continue;
2078 }
2079 if idx > 0 && dispatch_mode.is_degraded {
2081 let delay_ms = sequential_delay;
2082 tracing::debug!(
2083 task_id = task.id.0,
2084 delay_ms = delay_ms,
2085 "sequential dispatch: waiting before dispatch"
2086 );
2087 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
2088 }
2089 let dispatch_key = format!("{}/{}", repo, task.id.0);
2096 {
2101 use dashmap::mapref::entry::Entry;
2102 match dispatching.entry(dispatch_key.clone()) {
2103 Entry::Occupied(existing) => {
2104 let existing_id = existing.get().clone();
2105 drop(existing); if existing_id == task.id.0 {
2107 tracing::debug!(
2108 task_id = task.id.0,
2109 "task already dispatching, skipping duplicate"
2110 );
2111 } else {
2112 tracing::warn!(
2113 task_id = task.id.0,
2114 existing_task_id = existing_id,
2115 dispatch_key,
2116 "dispatch key collision: unexpected task already holds this key"
2117 );
2118 }
2119 continue;
2120 }
2121 Entry::Vacant(slot) => {
2122 slot.insert(task.id.0.clone());
2123 tracing::info!(task_id = task.id.0, dispatch_key, "dispatch guard claimed");
2130 }
2131 }
2132 }
2133 let dispatch_guard = DispatchGuard::new(dispatching.clone(), dispatch_key.clone());
2135 let task_id = task.id.0.clone();
2136
2137 let route_result = match get_route_result(store, repo, &task_id).await {
2140 Ok(r) => Some(r),
2141 Err(RouteResultError::NoAgent { .. }) => {
2142 tracing::warn!(
2143 task_id,
2144 "routed task missing agent — resetting to new for re-routing (#1604)"
2145 );
2146 if let Err(e2) = task_manager.update_task_status(&task.id, Status::New).await {
2147 tracing::error!(task_id, error = %e2, "failed to reset task to new");
2148 }
2149 continue; }
2151 Err(e) => {
2152 tracing::warn!(task_id, error = %e, "get_route_result failed — dispatching without route info");
2153 None
2154 }
2155 };
2156
2157 if let Some(route) = route_result.as_ref() {
2163 let agent_cooled = is_agent_in_cooldown(&route.agent);
2164 let model_cooled = route
2165 .model
2166 .as_deref()
2167 .is_some_and(|model| is_model_in_cooldown(&route.agent, model));
2168 if agent_cooled || model_cooled {
2169 tracing::warn!(
2170 task_id,
2171 agent = route.agent,
2172 model = route.model.as_deref().unwrap_or(""),
2173 "routed agent/model entered cooldown after routing — deferring dispatch to next tick"
2174 );
2175 continue; }
2177 }
2178
2179 let session_name = tmux.session_name(repo, &task_id);
2181 if tmux
2182 .session_blocks_dispatch_from_map(&session_name, session_map)
2183 .await
2184 {
2185 tracing::warn!(
2186 task_id,
2187 session_name,
2188 "task has existing tmux session, skipping dispatch"
2189 );
2190 continue; }
2192
2193 let permit = match semaphore.clone().try_acquire_owned() {
2195 Ok(p) => p,
2196 Err(_) => {
2197 tracing::debug!("all parallel slots busy, skipping remaining tasks");
2198 break; }
2200 };
2201
2202 let set_in_progress_result = task_manager
2204 .update_task_status(&task.id, Status::InProgress)
2205 .await;
2206 if let Err(e) = set_in_progress_result {
2207 tracing::error!(task_id, ?e, "failed to set in_progress, skipping dispatch");
2208 drop(permit);
2209 continue; }
2211 tracing::info!(task_id, "dispatching task");
2212
2213 capture
2215 .register_session(repo, &task_id, &session_name)
2216 .await;
2217
2218 let runner = runner.clone();
2220 let backend = backend.clone();
2221 let tmux = tmux.clone();
2222 let capture = capture.clone();
2223 let task_id_for_cleanup = task_id.clone();
2224 let task_owned = task;
2225 let weight_tx = weight_tx.clone();
2226 let repo_owned = repo.to_string();
2227 let task_manager_for_spawn = task_manager.clone();
2228 let store_for_spawn = store.clone();
2229
2230 let repo_ctx = repo_owned.clone();
2231 tokio::spawn(REPO_CONTEXT.scope(repo_ctx, async move {
2232 let _dispatch_guard = dispatch_guard; let dispatch_start = std::time::Instant::now();
2234 match runner
2235 .run_with_context(&task_owned, &backend, &tmux, route_result.as_ref())
2236 .await
2237 {
2238 Ok(signal) => {
2239 tracing::info!(task_id, "task runner completed");
2240
2241 let duration = dispatch_start.elapsed().as_secs_f64();
2242
2243 let enable_review = config::get("workflow.enable_review_agent")
2249 .map(|v| v != "false")
2250 .unwrap_or(true);
2251 let display_status = match &signal {
2252 WeightSignal::Success { .. } if enable_review => {
2253 let has_pr = match store_for_spawn
2257 .resolve_task_id(&repo_owned, &task_id)
2258 .await
2259 {
2260 Ok(Some(store_id)) => match store_for_spawn
2261 .get(store_id)
2262 .await
2263 {
2264 Ok(t) => t.pr_number.is_some(),
2265 Err(e) => {
2266 tracing::warn!(task_id, err = %e, "store.get() failed — defaulting to needs_review");
2267 true
2268 }
2269 },
2270 Ok(None) => false,
2271 Err(e) => {
2272 tracing::warn!(task_id, err = %e, "failed to check PR status — defaulting to needs_review");
2273 true
2274 }
2275 };
2276 if has_pr {
2277 "needs_review"
2278 } else {
2279 "done"
2280 }
2281 }
2282 WeightSignal::Success { .. } => "done",
2283 WeightSignal::RateLimited { .. } => "new",
2284 WeightSignal::Blocked => "blocked",
2285 WeightSignal::Rerouted => "new",
2290 WeightSignal::None if enable_review => "needs_review",
2295 WeightSignal::None => "done",
2296 };
2297
2298 let _ = weight_tx.send(signal).await;
2300
2301 if display_status == "needs_review" {
2305 if let Err(e) = task_manager_for_spawn
2308 .update_task_status_with_duration(
2309 &ExternalId(task_id.clone()),
2310 Status::NeedsReview,
2311 Some(duration),
2312 )
2313 .await
2314 {
2315 tracing::error!(task_id, err = %e, "update_task_status(NeedsReview) failed — task may be stuck");
2316 }
2317 } else {
2319 let final_status = match display_status {
2321 "done" => Status::Done,
2322 "blocked" => Status::Blocked,
2323 _ => Status::New,
2324 };
2325 if let Err(e) = task_manager_for_spawn
2326 .update_task_status_with_duration(
2327 &ExternalId(task_id.clone()),
2328 final_status,
2329 Some(duration),
2330 )
2331 .await
2332 {
2333 tracing::warn!(task_id, ?e, "failed to update task status after completion");
2334 }
2335 }
2336 }
2337 Err(e) => {
2338 tracing::error!(task_id, ?e, "task runner failed");
2339 let duration = dispatch_start.elapsed().as_secs_f64();
2340 if is_internal_id(&task_id) {
2341 if let Err(ue) = task_manager_for_spawn
2343 .update_task_status_with_duration(
2344 &ExternalId(task_id.clone()),
2345 Status::NeedsReview,
2346 Some(duration),
2347 )
2348 .await
2349 {
2350 tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
2351 }
2352 } else {
2353 if let Err(comment_err) = backend
2355 .post_comment(
2356 &ExternalId(task_id.clone()),
2357 &format!(
2358 "[{}] error: task runner failed: {e}{}",
2359 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
2360 crate::engine::orch_footer()
2361 ),
2362 )
2363 .await
2364 {
2365 tracing::warn!(
2366 task_id,
2367 ?comment_err,
2368 "failed to post error comment to GitHub"
2369 );
2370 }
2371 if let Err(ue) = task_manager_for_spawn
2375 .update_task_status_with_duration(
2376 &ExternalId(task_id.clone()),
2377 Status::NeedsReview,
2378 Some(duration),
2379 )
2380 .await
2381 {
2382 tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
2383 }
2384 }
2385 }
2386 }
2387
2388 capture.unregister_session(&repo_owned, &task_id_for_cleanup).await;
2390
2391 drop(permit);
2393 }));
2395 }
2396 Ok(())
2397}
2398
2399fn skips_agent(labels: &[String]) -> bool {
2400 labels
2401 .iter()
2402 .any(|label| label == "no-agent" || label == "owner:human")
2403}
2404
2405fn group_orphaned_routed_tasks(
2412 all_routed: Vec<crate::store::Task>,
2413 active_repos: &std::collections::HashSet<String>,
2414) -> std::collections::HashMap<String, Vec<crate::store::Task>> {
2415 let mut by_repo: std::collections::HashMap<String, Vec<crate::store::Task>> =
2416 std::collections::HashMap::new();
2417 for task in all_routed
2418 .into_iter()
2419 .filter(|t| !active_repos.contains(&t.repo))
2420 .filter(|t| !skips_agent(&t.labels))
2421 {
2422 by_repo.entry(task.repo.clone()).or_default().push(task);
2423 }
2424 by_repo
2425}
2426
2427#[allow(clippy::too_many_arguments)]
2447pub(crate) async fn dispatch_routed_tasks_global(
2448 tmux: &Arc<TmuxManager>,
2449 capture: &Arc<CaptureService>,
2450 semaphore: &Arc<Semaphore>,
2451 weight_tx: &mpsc::Sender<WeightSignal>,
2452 dispatch_mode: DispatchMode,
2453 dispatching: &Arc<DashMap<String, String>>,
2454 store: &Arc<TaskStore>,
2455 session_map: &std::collections::HashMap<String, bool>,
2456 active_repos: &std::collections::HashSet<String>,
2457) -> anyhow::Result<()> {
2458 use crate::store::TaskStatus;
2459
2460 let all_routed = store.list_all_by_status_global(TaskStatus::Routed).await?;
2461 let by_repo = group_orphaned_routed_tasks(all_routed, active_repos);
2462
2463 if by_repo.is_empty() {
2464 return Ok(());
2465 }
2466
2467 tracing::info!(
2468 repos = by_repo.len(),
2469 "global dispatch sweep: found routed tasks from inactive repos"
2470 );
2471
2472 for (repo, tasks) in by_repo {
2473 let backend: Arc<dyn ExternalBackend> = match crate::backends::github::GitHubBackend::new(
2474 repo.clone(),
2475 ) {
2476 Ok(b) => Arc::new(b),
2477 Err(e) => {
2478 tracing::warn!(repo = %repo, err = %e, "global sweep: failed to construct backend, skipping repo");
2479 continue;
2480 }
2481 };
2482 let task_manager = Arc::new(TaskManager::with_store(
2483 backend.clone(),
2484 store.clone(),
2485 repo.clone(),
2486 ));
2487 let runner = Arc::new(TaskRunner::new(repo.clone()).with_store(store.clone()));
2488 let dispatchable: Vec<ExternalTask> = tasks
2489 .iter()
2490 .map(crate::engine::tasks::store_task_to_external)
2491 .collect();
2492
2493 if let Err(e) = dispatch_tasks_for_repo(
2494 &backend,
2495 tmux,
2496 &repo,
2497 &runner,
2498 capture,
2499 semaphore,
2500 &task_manager,
2501 weight_tx,
2502 dispatch_mode,
2503 dispatching,
2504 store,
2505 session_map,
2506 dispatchable,
2507 )
2508 .await
2509 {
2510 tracing::warn!(repo = %repo, err = %e, "global sweep: dispatch failed for repo");
2511 }
2512 }
2513
2514 Ok(())
2515}
2516
2517pub(crate) async fn tick_unblock_parents(
2519 backend: &Arc<dyn ExternalBackend>,
2520 task_manager: &Arc<TaskManager>,
2521 store: &Arc<TaskStore>,
2522 repo: &str,
2523) -> anyhow::Result<()> {
2524 let blocked = task_manager.list_all_by_status(Status::Blocked).await?;
2525
2526 let store_blocked = store
2530 .list_by_status(repo, crate::store::TaskStatus::Blocked)
2531 .await?;
2532 let has_block_reason: std::collections::HashSet<String> = store_blocked
2533 .iter()
2534 .filter(|t| t.block_reason.is_some())
2535 .filter_map(|t| t.external_id.clone())
2536 .collect();
2537
2538 let handles: Vec<_> = blocked
2542 .into_iter()
2543 .filter(|task| {
2544 if has_block_reason.contains(&task.id.0) {
2545 tracing::debug!(
2546 task_id = task.id.0,
2547 "skipping blocked task with known block_reason"
2548 );
2549 false
2550 } else {
2551 true
2552 }
2553 })
2554 .map(|task| {
2555 let backend_clone = Arc::clone(backend);
2556 let task_manager_clone = Arc::clone(task_manager);
2557 let store_clone = Arc::clone(store);
2558 let repo_owned = repo.to_string();
2559 tokio::spawn(async move {
2560 let task_id = &task.id;
2561
2562 let mut children: Vec<ExternalId> = Vec::new();
2563 let mut child_statuses: std::collections::HashMap<
2564 String,
2565 crate::store::TaskStatus,
2566 > = std::collections::HashMap::new();
2567
2568 match store_clone.resolve_task_id(&repo_owned, &task_id.0).await {
2569 Ok(Some(parent_store_id)) => {
2570 match store_clone.list_children(&repo_owned, parent_store_id).await {
2571 Ok(store_children) => {
2572 for child in store_children {
2573 let Some(child_external_id) = child.external_id else {
2574 continue;
2575 };
2576 child_statuses.insert(child_external_id.clone(), child.status);
2577 children.push(ExternalId(child_external_id));
2578 }
2579 }
2580 Err(e) => {
2581 tracing::debug!(
2582 task_id = task_id.0,
2583 ?e,
2584 "failed to list store-linked children"
2585 );
2586 }
2587 }
2588 }
2589 Ok(None) => {}
2590 Err(e) => {
2591 tracing::debug!(
2592 task_id = task_id.0,
2593 ?e,
2594 "failed to resolve blocked task in store for child lookup"
2595 );
2596 }
2597 }
2598
2599 if !is_internal_id(&task_id.0) {
2600 match backend_clone.get_sub_issues(task_id).await {
2601 Ok(ids) => {
2602 let mut seen: std::collections::HashSet<String> =
2603 children.iter().map(|child| child.0.clone()).collect();
2604 for id in ids {
2605 if seen.insert(id.0.clone()) {
2606 children.push(id);
2607 }
2608 }
2609 }
2610 Err(e) => {
2611 tracing::debug!(task_id = task_id.0, ?e, "failed to get sub-issues");
2612 }
2613 }
2614 }
2615
2616 if children.is_empty() {
2618 return;
2619 }
2620
2621 let mut all_done = true;
2624 let child_ext_strs: Vec<String> =
2626 children.iter().map(|c| c.0.clone()).collect();
2627 let child_exts: Vec<&str> =
2628 child_ext_strs.iter().map(|s| s.as_str()).collect();
2629 if let Ok(map) = store_clone
2631 .get_statuses_by_external_ids(&repo_owned, &child_exts)
2632 .await
2633 {
2634 for (k, v) in map.into_iter() {
2635 child_statuses.insert(k, v);
2636 }
2637 } else {
2638 tracing::debug!(
2639 task_id = task_id.0,
2640 "failed to batch-query store for child statuses; falling back to per-child lookups"
2641 );
2642 }
2643
2644 for child_id in &children {
2645 if let Some(status) = child_statuses.get(&child_id.0) {
2647 if *status != crate::store::TaskStatus::Done {
2648 all_done = false;
2649 break;
2650 }
2651 continue;
2652 }
2653
2654 match backend_clone.get_task(child_id).await {
2656 Ok(child) => {
2657 if !child.labels.iter().any(|l| l == Status::Done.as_label()) {
2658 all_done = false;
2659 break;
2660 }
2661 }
2662 Err(e) => {
2663 tracing::debug!(task_id = task_id.0, child = %child_id.0, ?e, "failed to fetch child task from backend — treating as not done");
2664 all_done = false;
2665 break;
2666 }
2667 }
2668 }
2669
2670 if all_done {
2671 tracing::info!(
2672 task_id = task_id.0,
2673 children = children.len(),
2674 "all children done, unblocking parent"
2675 );
2676 if let Err(e) =
2677 task_manager_clone.update_task_status(task_id, Status::New).await
2678 {
2679 tracing::warn!(task_id = task_id.0, ?e, "failed to unblock parent");
2680 }
2681 }
2682 })
2683 })
2684 .collect();
2685
2686 for handle in handles {
2687 if let Err(e) = handle.await {
2688 tracing::warn!(?e, "phase 4 sub-issue check panicked");
2689 }
2690 }
2691 Ok(())
2692}
2693
2694pub(crate) async fn tick_job_scheduler(
2696 jobs_path: &std::path::Path,
2697 backend: &Arc<dyn ExternalBackend>,
2698 store: Option<&Arc<TaskStore>>,
2699 repo: &str,
2700 transport: Option<&Arc<crate::channels::transport::Transport>>,
2701) -> anyhow::Result<()> {
2702 jobs::tick(jobs_path, backend, store, repo, transport).await
2703}
2704
2705#[allow(clippy::too_many_arguments)]
2715pub(crate) async fn tick(
2716 backend: &Arc<dyn ExternalBackend>,
2717 tmux: &Arc<TmuxManager>,
2718 repo: &str,
2719 runner: &Arc<TaskRunner>,
2720 capture: &Arc<CaptureService>,
2721 semaphore: &Arc<Semaphore>,
2722 config: &EngineConfig,
2723 jobs_path: &std::path::Path,
2724 router: &mut Router,
2725 task_manager: &Arc<TaskManager>,
2726 weight_tx: &mpsc::Sender<WeightSignal>,
2727 dispatching: &Arc<DashMap<String, String>>,
2728 store: &Arc<TaskStore>,
2729 transport: Option<&Arc<crate::channels::transport::Transport>>,
2730 active_repos: &std::collections::HashSet<String>,
2731 auto_merge_in_flight: &Arc<DashSet<String>>,
2732 routing_quota: &mut RoutingTickQuota,
2733) -> anyhow::Result<()> {
2734 let _tick_span = tracing::info_span!("engine.tick").entered();
2735
2736 for (project, call_type, calls) in crate::github::http::drain_call_counts()
2738 .into_iter()
2739 .take(10)
2740 {
2741 tracing::debug!(
2742 project,
2743 call_type,
2744 calls,
2745 "github api calls since last tick"
2746 );
2747 }
2748
2749 startup_cleanup(tmux).await;
2751
2752 if let Err(e) = tick_check_session_completions(tmux, repo, capture, store).await {
2753 tracing::warn!(err = %e, "session completion phase failed");
2754 }
2755 if let Err(e) =
2756 tick_detect_silent_agents(tmux, repo, capture, backend, task_manager, config, store).await
2757 {
2758 tracing::warn!(err = %e, "silent-agent detection phase failed");
2759 }
2760 let session_map = tmux.batch_session_active().await;
2763 if let Err(e) = tick_recover_stuck_tasks(
2764 backend,
2765 tmux,
2766 repo,
2767 task_manager,
2768 config,
2769 store,
2770 &session_map,
2771 router,
2772 dispatching,
2773 auto_merge_in_flight,
2774 )
2775 .await
2776 {
2777 tracing::warn!(err = %e, "stuck-task recovery phase failed");
2778 }
2779 if let Err(err) = crate::engine::dependencies::reconcile_dependencies(
2780 backend.as_ref(),
2781 store,
2782 transport.map(|arc| arc.as_ref()),
2783 )
2784 .await
2785 {
2786 tracing::warn!(err = %err, "dependency reconcile failed");
2787 }
2788 if let Err(e) =
2789 tick_route_tasks(backend, task_manager, router, store, repo, routing_quota).await
2790 {
2791 tracing::warn!(err = %e, "routing phase failed");
2792 }
2793 if let Err(e) = route_new_tasks_global(router, store, active_repos, routing_quota).await {
2797 tracing::warn!(err = %e, "global new-task routing sweep failed");
2798 }
2799 let dispatch_mode = dispatch_mode_from_router(router);
2800 if let Err(e) = tick_dispatch_tasks(
2801 backend,
2802 tmux,
2803 repo,
2804 runner,
2805 capture,
2806 semaphore,
2807 task_manager,
2808 weight_tx,
2809 dispatch_mode,
2810 dispatching,
2811 store,
2812 &session_map,
2813 )
2814 .await
2815 {
2816 tracing::warn!(err = %e, "dispatch phase failed");
2817 }
2818 if let Err(e) = dispatch_routed_tasks_global(
2824 tmux,
2825 capture,
2826 semaphore,
2827 weight_tx,
2828 dispatch_mode,
2829 dispatching,
2830 store,
2831 &session_map,
2832 active_repos,
2833 )
2834 .await
2835 {
2836 tracing::warn!(err = %e, "global routed-task dispatch sweep failed");
2837 }
2838 if let Err(e) = tick_unblock_parents(backend, task_manager, store, repo).await {
2839 tracing::warn!(err = %e, "parent-unblock phase failed");
2840 }
2841 if let Err(e) = tick_job_scheduler(jobs_path, backend, Some(store), repo, transport).await {
2842 tracing::error!(?e, "job scheduler tick failed");
2843 }
2844 Ok(())
2845}
2846
2847#[cfg(test)]
2848mod tests {
2849 use super::*;
2850 use crate::backends::{ExternalId, ExternalTask, Mention, Status};
2851 use crate::channels::transport::Transport;
2852 use crate::engine::tasks::TaskManager;
2853 use crate::store::TaskStore;
2854 use crate::tmux::{SessionGuard, TmuxManager};
2855 use async_trait::async_trait;
2856 use std::sync::{Arc, Mutex};
2857
2858 #[test]
2859 fn skips_agent_for_human_and_no_agent_labels() {
2860 assert!(skips_agent(&["owner:human".to_string()]));
2861 assert!(skips_agent(&["no-agent".to_string()]));
2862 assert!(!skips_agent(&["status:new".to_string()]));
2863 }
2864
2865 fn cooled_review_router_for_test() -> Router {
2869 Router::new_for_test(
2870 crate::engine::router::RouterConfig::default(),
2871 vec!["claude".to_string()],
2872 )
2873 }
2874
2875 struct MockBackend {
2879 blocked_tasks: Vec<ExternalTask>,
2881 sub_issues: std::collections::HashMap<String, Vec<ExternalId>>,
2883 tasks_by_id: std::collections::HashMap<String, ExternalTask>,
2885 get_sub_issues_calls: Arc<Mutex<Vec<String>>>,
2887 status_updates: Arc<Mutex<Vec<(String, Status)>>>,
2889 removed_labels: Arc<Mutex<Vec<(String, String)>>>,
2891 posted_comments: Arc<Mutex<Vec<(String, String)>>>,
2893 }
2894
2895 impl MockBackend {
2896 fn new() -> Self {
2897 Self {
2898 blocked_tasks: vec![],
2899 sub_issues: Default::default(),
2900 tasks_by_id: Default::default(),
2901 get_sub_issues_calls: Arc::new(Mutex::new(vec![])),
2902 status_updates: Arc::new(Mutex::new(vec![])),
2903 removed_labels: Arc::new(Mutex::new(vec![])),
2904 posted_comments: Arc::new(Mutex::new(vec![])),
2905 }
2906 }
2907 }
2908
2909 fn make_task(id: &str, labels: &[&str]) -> ExternalTask {
2910 ExternalTask {
2911 id: ExternalId(id.to_string()),
2912 title: format!("Task {id}"),
2913 body: "".to_string(),
2914 state: "open".to_string(),
2915 labels: labels.iter().map(|s| s.to_string()).collect(),
2916 author: "bot".to_string(),
2917 created_at: "2026-01-01T00:00:00Z".to_string(),
2918 updated_at: "2026-01-01T00:00:00Z".to_string(),
2919 url: format!("https://github.com/test/test/issues/{id}"),
2920 }
2921 }
2922
2923 #[async_trait]
2924 impl ExternalBackend for MockBackend {
2925 fn name(&self) -> &str {
2926 "mock"
2927 }
2928 async fn create_task(
2929 &self,
2930 _title: &str,
2931 _body: &str,
2932 _labels: &[String],
2933 ) -> anyhow::Result<ExternalId> {
2934 Ok(ExternalId("new".to_string()))
2935 }
2936 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
2937 self.tasks_by_id
2938 .get(&id.0)
2939 .cloned()
2940 .ok_or_else(|| anyhow::anyhow!("task not found: {}", id.0))
2941 }
2942 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
2943 if status == Status::Blocked {
2944 Ok(self.blocked_tasks.clone())
2945 } else {
2946 Ok(vec![])
2947 }
2948 }
2949 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2950 Ok(vec![])
2951 }
2952 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
2953 self.posted_comments
2954 .lock()
2955 .unwrap()
2956 .push((_id.0.clone(), _body.to_string()));
2957 Ok(())
2958 }
2959 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2960 Ok(())
2961 }
2962 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2963 self.removed_labels
2964 .lock()
2965 .unwrap()
2966 .push((_id.0.clone(), _label.to_string()));
2967 Ok(())
2968 }
2969 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2970 self.get_sub_issues_calls.lock().unwrap().push(id.0.clone());
2971 Ok(self.sub_issues.get(&id.0).cloned().unwrap_or_default())
2972 }
2973 async fn create_sub_task(
2974 &self,
2975 _parent: &ExternalId,
2976 _title: &str,
2977 _body: &str,
2978 _labels: &[String],
2979 ) -> anyhow::Result<ExternalId> {
2980 Ok(ExternalId("child".to_string()))
2981 }
2982 async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
2983 Ok(())
2984 }
2985 async fn has_open_issue_with_title(
2986 &self,
2987 _title: &str,
2988 _label: &str,
2989 ) -> anyhow::Result<bool> {
2990 Ok(false)
2991 }
2992 async fn health_check(&self) -> anyhow::Result<()> {
2993 Ok(())
2994 }
2995 async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
2996 Ok(false)
2997 }
2998 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
2999 Ok(Some("testbot".to_string()))
3000 }
3001 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
3002 Ok(vec![])
3003 }
3004 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
3005 self.status_updates
3006 .lock()
3007 .unwrap()
3008 .push((id.0.clone(), status));
3009 Ok(())
3010 }
3011 }
3012
3013 fn make_task_manager(backend: Arc<dyn ExternalBackend>) -> Arc<TaskManager> {
3014 Arc::new(TaskManager::new(backend))
3015 }
3016
3017 #[tokio::test]
3020 async fn unblock_parents_unblocks_when_all_children_done() {
3021 let mut mock = MockBackend::new();
3022
3023 let parent = make_task("10", &["status:blocked"]);
3025 mock.blocked_tasks.push(parent.clone());
3026 mock.sub_issues.insert(
3027 "10".to_string(),
3028 vec![ExternalId("11".to_string()), ExternalId("12".to_string())],
3029 );
3030 mock.tasks_by_id
3031 .insert("11".to_string(), make_task("11", &["status:done"]));
3032 mock.tasks_by_id
3033 .insert("12".to_string(), make_task("12", &["status:done"]));
3034
3035 let status_updates = mock.status_updates.clone();
3036 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3037 let task_manager = make_task_manager(backend.clone());
3038 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3039
3040 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3041 .await
3042 .unwrap();
3043
3044 let updates = status_updates.lock().unwrap();
3045 assert_eq!(updates.len(), 1, "parent should be unblocked");
3046 assert_eq!(updates[0], ("10".to_string(), Status::New));
3047 }
3048
3049 #[tokio::test]
3050 async fn unblock_parents_skips_when_child_not_done() {
3051 let mut mock = MockBackend::new();
3052
3053 let parent = make_task("20", &["status:blocked"]);
3054 mock.blocked_tasks.push(parent.clone());
3055 mock.sub_issues.insert(
3056 "20".to_string(),
3057 vec![ExternalId("21".to_string()), ExternalId("22".to_string())],
3058 );
3059 mock.tasks_by_id
3061 .insert("21".to_string(), make_task("21", &["status:done"]));
3062 mock.tasks_by_id
3063 .insert("22".to_string(), make_task("22", &["status:in_progress"]));
3064
3065 let status_updates = mock.status_updates.clone();
3066 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3067 let task_manager = make_task_manager(backend.clone());
3068 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3069
3070 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3071 .await
3072 .unwrap();
3073
3074 let updates = status_updates.lock().unwrap();
3075 assert!(
3076 updates.is_empty(),
3077 "should not unblock when a child is still running"
3078 );
3079 }
3080
3081 #[tokio::test]
3082 async fn unblock_parents_skips_task_with_no_children() {
3083 let mut mock = MockBackend::new();
3084
3085 let parent = make_task("30", &["status:blocked"]);
3087 mock.blocked_tasks.push(parent.clone());
3088 let status_updates = mock.status_updates.clone();
3091 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3092 let task_manager = make_task_manager(backend.clone());
3093 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3094
3095 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3096 .await
3097 .unwrap();
3098
3099 let updates = status_updates.lock().unwrap();
3100 assert!(
3101 updates.is_empty(),
3102 "should not unblock tasks with no sub-issues"
3103 );
3104 }
3105
3106 #[tokio::test]
3107 async fn unblock_parents_skips_task_with_block_reason_in_store() {
3108 let mut mock = MockBackend::new();
3111
3112 let parent = make_task("60", &["status:blocked"]);
3114 mock.blocked_tasks.push(parent.clone());
3115 mock.sub_issues
3118 .insert("60".to_string(), vec![ExternalId("61".to_string())]);
3119 mock.tasks_by_id
3120 .insert("61".to_string(), make_task("61", &["status:done"]));
3121
3122 let status_updates = mock.status_updates.clone();
3123 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3124 let task_manager = make_task_manager(backend.clone());
3125
3126 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3128 let task_id = store
3129 .upsert_external(&crate::store::UpsertExternal {
3130 repo: "test/repo",
3131 ext_id: "60",
3132 title: "Task 60",
3133 body: "",
3134 author: "bot",
3135 url: "https://github.com/test/test/issues/60",
3136 labels: &[],
3137 origin: "github",
3138 })
3139 .await
3140 .unwrap();
3141 store
3142 .update_status(task_id, crate::store::TaskStatus::Blocked)
3143 .await
3144 .unwrap();
3145 store
3146 .set_block_reason(task_id, Some("CI failure limit reached"))
3147 .await
3148 .unwrap();
3149
3150 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3151 .await
3152 .unwrap();
3153
3154 let updates = status_updates.lock().unwrap();
3155 assert!(
3156 updates.is_empty(),
3157 "task with block_reason should be skipped — no API call, no unblock"
3158 );
3159 }
3160
3161 #[tokio::test]
3162 async fn unblock_parents_unblocks_internal_parent_from_store_children() {
3163 let mock = MockBackend::new();
3164 let get_sub_issues_calls = mock.get_sub_issues_calls.clone();
3165 let status_updates = mock.status_updates.clone();
3166 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3167 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3168 let task_manager = Arc::new(TaskManager::with_store(
3169 backend.clone(),
3170 store.clone(),
3171 "test/repo".to_string(),
3172 ));
3173
3174 let parent_id = store
3175 .create_internal("test/repo", "Parent", "", "job", "daily", None)
3176 .await
3177 .unwrap();
3178 store
3179 .update_status(parent_id, crate::store::TaskStatus::Blocked)
3180 .await
3181 .unwrap();
3182 let child_id = store
3183 .create(&crate::store::NewTask {
3184 external_id: Some("91".to_string()),
3185 repo: "test/repo".to_string(),
3186 origin: "github".to_string(),
3187 title: "Child".to_string(),
3188 body: String::new(),
3189 source: String::new(),
3190 source_id: String::new(),
3191 author: String::new(),
3192 url: String::new(),
3193 labels: vec![],
3194 parent_id: Some(parent_id),
3195 })
3196 .await
3197 .unwrap();
3198 store
3199 .update_status(child_id, crate::store::TaskStatus::Done)
3200 .await
3201 .unwrap();
3202
3203 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3204 .await
3205 .unwrap();
3206
3207 let parent = store.get(parent_id).await.unwrap();
3208 assert_eq!(parent.status, crate::store::TaskStatus::New);
3209 assert!(
3210 status_updates.lock().unwrap().is_empty(),
3211 "internal parent should be updated in store without backend mirroring"
3212 );
3213 assert!(
3214 get_sub_issues_calls.lock().unwrap().is_empty(),
3215 "internal parent should not query backend sub-issues"
3216 );
3217 }
3218
3219 #[cfg(test)]
3223 async fn set_task_updated_at_past(store: &TaskStore, task_id: i64) {
3224 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
3225 .bind(task_id)
3226 .execute(store.pool())
3227 .await
3228 .unwrap();
3229 }
3230
3231 #[tokio::test]
3232 async fn recover_stuck_tasks_resets_external_in_review_to_needs_review() {
3233 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3234 let mock = MockBackend::new();
3235 let status_updates = mock.status_updates.clone();
3236 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3237 let task_manager = Arc::new(TaskManager::with_store(
3238 backend.clone(),
3239 store.clone(),
3240 "owner/repo".to_string(),
3241 ));
3242 let tmux = Arc::new(TmuxManager::new());
3243 let config = EngineConfig {
3244 no_session_stuck_timeout: 600,
3245 stuck_timeout: 1800,
3246 ..EngineConfig::default()
3247 };
3248
3249 let id = store
3251 .upsert_external(&crate::store::UpsertExternal {
3252 repo: "owner/repo",
3253 ext_id: "99",
3254 title: "Stuck InReview",
3255 body: "",
3256 author: "",
3257 url: "",
3258 labels: &[],
3259 origin: "github",
3260 })
3261 .await
3262 .unwrap();
3263 store
3264 .update_status(id, crate::store::TaskStatus::InReview)
3265 .await
3266 .unwrap();
3267 set_review_session_expected(&store, "owner/repo", "99", true).await;
3268 set_task_updated_at_past(&store, id).await;
3269
3270 tick_recover_stuck_tasks(
3271 &backend,
3272 &tmux,
3273 "owner/repo",
3274 &task_manager,
3275 &config,
3276 &store,
3277 &std::collections::HashMap::new(),
3278 &cooled_review_router_for_test(),
3279 &Arc::new(DashMap::new()),
3280 &Arc::new(DashSet::new()),
3281 )
3282 .await
3283 .unwrap();
3284
3285 let updates = status_updates.lock().unwrap();
3286 assert_eq!(updates.len(), 1, "stuck InReview task should be recovered");
3287 assert_eq!(updates[0].0, "99");
3288 assert_eq!(updates[0].1, Status::NeedsReview);
3289 }
3290
3291 #[tokio::test]
3292 async fn recover_stuck_tasks_skips_recent_in_review() {
3293 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3294 let mock = MockBackend::new();
3295 let status_updates = mock.status_updates.clone();
3296 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3297 let task_manager = Arc::new(TaskManager::with_store(
3298 backend.clone(),
3299 store.clone(),
3300 "owner/repo".to_string(),
3301 ));
3302 let tmux = Arc::new(TmuxManager::new());
3303 let config = EngineConfig {
3304 no_session_stuck_timeout: 600, stuck_timeout: 1800,
3306 ..EngineConfig::default()
3307 };
3308
3309 let id = store
3311 .upsert_external(&crate::store::UpsertExternal {
3312 repo: "owner/repo",
3313 ext_id: "100",
3314 title: "Recent InReview",
3315 body: "",
3316 author: "",
3317 url: "",
3318 labels: &[],
3319 origin: "github",
3320 })
3321 .await
3322 .unwrap();
3323 store
3324 .update_status(id, crate::store::TaskStatus::InReview)
3325 .await
3326 .unwrap();
3327 tick_recover_stuck_tasks(
3330 &backend,
3331 &tmux,
3332 "owner/repo",
3333 &task_manager,
3334 &config,
3335 &store,
3336 &std::collections::HashMap::new(),
3337 &cooled_review_router_for_test(),
3338 &Arc::new(DashMap::new()),
3339 &Arc::new(DashSet::new()),
3340 )
3341 .await
3342 .unwrap();
3343
3344 let updates = status_updates.lock().unwrap();
3345 assert!(
3346 updates.is_empty(),
3347 "recent InReview task should not be recovered"
3348 );
3349 }
3350
3351 #[tokio::test]
3352 async fn recover_stuck_tasks_resets_internal_in_review_to_needs_review() {
3353 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3354 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3355 let task_manager = Arc::new(TaskManager::with_store(
3356 backend.clone(),
3357 store.clone(),
3358 "owner/repo".to_string(),
3359 ));
3360 let tmux = Arc::new(TmuxManager::new());
3361 let config = EngineConfig {
3362 no_session_stuck_timeout: 600,
3363 stuck_timeout: 1800,
3364 ..EngineConfig::default()
3365 };
3366
3367 let id = store
3369 .create_internal("owner/repo", "Internal InReview", "", "cron", "1", None)
3370 .await
3371 .unwrap();
3372 store
3373 .update_status(id, crate::store::TaskStatus::InReview)
3374 .await
3375 .unwrap();
3376 set_review_session_expected(&store, "owner/repo", "internal:1", true).await;
3377 set_task_updated_at_past(&store, id).await;
3378
3379 tick_recover_stuck_tasks(
3380 &backend,
3381 &tmux,
3382 "owner/repo",
3383 &task_manager,
3384 &config,
3385 &store,
3386 &std::collections::HashMap::new(),
3387 &cooled_review_router_for_test(),
3388 &Arc::new(DashMap::new()),
3389 &Arc::new(DashSet::new()),
3390 )
3391 .await
3392 .unwrap();
3393
3394 let task = store.get(id).await.unwrap();
3395 assert_eq!(
3396 task.status,
3397 crate::store::TaskStatus::NeedsReview,
3398 "stuck internal InReview task should be reset to NeedsReview"
3399 );
3400 }
3401
3402 #[tokio::test]
3403 async fn recover_stuck_tasks_resets_cross_repo_external_in_progress_to_new() {
3404 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3405 let mock = MockBackend::new();
3406 let status_updates = mock.status_updates.clone();
3407 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3408 let task_manager = Arc::new(TaskManager::with_store(
3409 backend.clone(),
3410 store.clone(),
3411 "owner/repo".to_string(),
3412 ));
3413 let tmux = Arc::new(TmuxManager::new());
3414 let config = EngineConfig {
3415 no_session_stuck_timeout: 600,
3416 stuck_timeout: 1800,
3417 ..EngineConfig::default()
3418 };
3419
3420 let id = store
3422 .upsert_external(&crate::store::UpsertExternal {
3423 repo: "owner/other",
3424 ext_id: "777",
3425 title: "Cross-repo stuck task",
3426 body: "",
3427 author: "",
3428 url: "",
3429 labels: &[],
3430 origin: "github",
3431 })
3432 .await
3433 .unwrap();
3434 store
3435 .update_status(id, crate::store::TaskStatus::InProgress)
3436 .await
3437 .unwrap();
3438 set_task_updated_at_past(&store, id).await;
3439
3440 tick_recover_stuck_tasks(
3441 &backend,
3442 &tmux,
3443 "owner/repo",
3444 &task_manager,
3445 &config,
3446 &store,
3447 &std::collections::HashMap::new(),
3448 &cooled_review_router_for_test(),
3449 &Arc::new(DashMap::new()),
3450 &Arc::new(DashSet::new()),
3451 )
3452 .await
3453 .unwrap();
3454
3455 let task = store.get(id).await.unwrap();
3456 assert_eq!(
3457 task.status,
3458 crate::store::TaskStatus::New,
3459 "cross-repo stale in_progress task should be reset to New"
3460 );
3461 assert_eq!(task.route_attempts, 0, "route attempts should be cleared");
3462 assert!(task.agent.is_none(), "agent should be cleared");
3463 assert!(task.model.is_none(), "model should be cleared");
3464
3465 assert!(
3467 status_updates.lock().unwrap().is_empty(),
3468 "cross-repo recovery should not call backend update_status"
3469 );
3470 }
3471
3472 #[tokio::test]
3473 async fn recover_stuck_tasks_finalizes_open_task_runs_rows() {
3474 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3475 let mock = MockBackend::new();
3476 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3477 let task_manager = Arc::new(TaskManager::with_store(
3478 backend.clone(),
3479 store.clone(),
3480 "owner/repo".to_string(),
3481 ));
3482 let tmux = Arc::new(TmuxManager::new());
3483 let config = EngineConfig {
3484 no_session_stuck_timeout: 600,
3485 stuck_timeout: 1800,
3486 ..EngineConfig::default()
3487 };
3488
3489 let id = store
3490 .create_internal("owner/repo", "Stuck with open run", "", "cron", "1", None)
3491 .await
3492 .unwrap();
3493 store
3494 .set_fields(
3495 id,
3496 &[
3497 ("agent", serde_json::json!("claude")),
3498 ("model", serde_json::json!("sonnet")),
3499 ("route_attempts", serde_json::json!(1)),
3500 ],
3501 )
3502 .await
3503 .unwrap();
3504 store
3505 .update_status(id, crate::store::TaskStatus::InProgress)
3506 .await
3507 .unwrap();
3508 set_task_updated_at_past(&store, id).await;
3509
3510 store
3512 .start_run(&crate::store::StartRun {
3513 task_id: id,
3514 attempt: 1,
3515 run_type: "agent",
3516 agent: "claude",
3517 model: "sonnet",
3518 command: "claude --model sonnet",
3519 prompt: "fix it",
3520 })
3521 .await
3522 .unwrap();
3523
3524 tick_recover_stuck_tasks(
3525 &backend,
3526 &tmux,
3527 "owner/repo",
3528 &task_manager,
3529 &config,
3530 &store,
3531 &std::collections::HashMap::new(),
3532 &cooled_review_router_for_test(),
3533 &Arc::new(DashMap::new()),
3534 &Arc::new(DashSet::new()),
3535 )
3536 .await
3537 .unwrap();
3538
3539 let task = store.get(id).await.unwrap();
3540 assert_eq!(task.status, crate::store::TaskStatus::New);
3541
3542 let runs = store.get_runs(id).await.unwrap();
3543 assert_eq!(runs.len(), 1);
3544 assert_eq!(runs[0].outcome, "aborted");
3545 assert!(runs[0].completed_at.is_some());
3546 assert_eq!(
3547 runs[0].error,
3548 "stuck-task recovery: internal no session found"
3549 );
3550 }
3551
3552 #[tokio::test]
3569 async fn recover_stuck_tasks_skips_reclaim_while_dispatch_guard_held() {
3570 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3571 let mock = MockBackend::new();
3572 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3573 let task_manager = Arc::new(TaskManager::with_store(
3574 backend.clone(),
3575 store.clone(),
3576 "owner/repo".to_string(),
3577 ));
3578 let tmux = Arc::new(TmuxManager::new());
3579 let config = EngineConfig {
3580 no_session_stuck_timeout: 600,
3581 stuck_timeout: 1800,
3582 ..EngineConfig::default()
3583 };
3584
3585 let id = store
3586 .create_internal(
3587 "owner/repo",
3588 "Long-running daily job",
3589 "",
3590 "cron",
3591 "1",
3592 None,
3593 )
3594 .await
3595 .unwrap();
3596 store
3597 .set_fields(
3598 id,
3599 &[
3600 ("agent", serde_json::json!("claude")),
3601 ("model", serde_json::json!("sonnet")),
3602 ("route_attempts", serde_json::json!(1)),
3603 ],
3604 )
3605 .await
3606 .unwrap();
3607 store
3608 .update_status(id, crate::store::TaskStatus::InProgress)
3609 .await
3610 .unwrap();
3611 set_task_updated_at_past(&store, id).await;
3614
3615 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
3618 dispatching.insert(
3619 format!("owner/repo/internal:{id}"),
3620 format!("internal:{id}"),
3621 );
3622
3623 tick_recover_stuck_tasks(
3624 &backend,
3625 &tmux,
3626 "owner/repo",
3627 &task_manager,
3628 &config,
3629 &store,
3630 &std::collections::HashMap::new(),
3631 &cooled_review_router_for_test(),
3632 &dispatching,
3633 &Arc::new(DashSet::new()),
3634 )
3635 .await
3636 .unwrap();
3637
3638 let task = store.get(id).await.unwrap();
3639 assert_eq!(
3640 task.status,
3641 crate::store::TaskStatus::InProgress,
3642 "task should not be reclaimed while its dispatch guard is still held"
3643 );
3644
3645 let runs = store.get_runs(id).await.unwrap();
3647 assert!(runs.is_empty());
3648
3649 dispatching.clear();
3652 tick_recover_stuck_tasks(
3653 &backend,
3654 &tmux,
3655 "owner/repo",
3656 &task_manager,
3657 &config,
3658 &store,
3659 &std::collections::HashMap::new(),
3660 &cooled_review_router_for_test(),
3661 &dispatching,
3662 &Arc::new(DashSet::new()),
3663 )
3664 .await
3665 .unwrap();
3666 let task = store.get(id).await.unwrap();
3667 assert_eq!(
3668 task.status,
3669 crate::store::TaskStatus::New,
3670 "task should still be reclaimed once the dispatch guard is released"
3671 );
3672 }
3673
3674 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
3690 async fn recover_stuck_tasks_never_reclaims_while_guard_held_under_real_concurrency() {
3691 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3692 let mock = MockBackend::new();
3693 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3694 let task_manager = Arc::new(TaskManager::with_store(
3695 backend.clone(),
3696 store.clone(),
3697 "owner/repo".to_string(),
3698 ));
3699 let tmux = Arc::new(TmuxManager::new());
3700 let config = EngineConfig {
3701 no_session_stuck_timeout: 600,
3702 stuck_timeout: 1800,
3703 ..EngineConfig::default()
3704 };
3705
3706 let id = store
3707 .create_internal(
3708 "owner/repo",
3709 "Long-running daily job",
3710 "",
3711 "cron",
3712 "1",
3713 None,
3714 )
3715 .await
3716 .unwrap();
3717 store
3718 .set_fields(
3719 id,
3720 &[
3721 ("agent", serde_json::json!("claude")),
3722 ("model", serde_json::json!("sonnet")),
3723 ("route_attempts", serde_json::json!(1)),
3724 ],
3725 )
3726 .await
3727 .unwrap();
3728 store
3729 .update_status(id, crate::store::TaskStatus::InProgress)
3730 .await
3731 .unwrap();
3732 set_task_updated_at_past(&store, id).await;
3733
3734 let dispatch_key = format!("owner/repo/internal:{id}");
3735 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
3736
3737 let claim_task_id = format!("internal:{id}");
3744 {
3745 use dashmap::mapref::entry::Entry;
3746 match dispatching.entry(dispatch_key.clone()) {
3747 Entry::Vacant(slot) => {
3748 slot.insert(claim_task_id);
3749 }
3750 Entry::Occupied(_) => panic!("dispatch key already claimed"),
3751 }
3752 }
3753 let guard = DispatchGuard::new(Arc::clone(&dispatching), dispatch_key.clone());
3754
3755 let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
3766 let holder = tokio::spawn(async move {
3767 let _ = release_rx.await;
3768 drop(guard);
3769 });
3770
3771 const HAMMER_ITERATIONS: u32 = 300;
3779 for i in 0..HAMMER_ITERATIONS {
3780 tick_recover_stuck_tasks(
3781 &backend,
3782 &tmux,
3783 "owner/repo",
3784 &task_manager,
3785 &config,
3786 &store,
3787 &std::collections::HashMap::new(),
3788 &cooled_review_router_for_test(),
3789 &dispatching,
3790 &Arc::new(DashSet::new()),
3791 )
3792 .await
3793 .unwrap();
3794
3795 let task = store.get(id).await.unwrap();
3796 assert_eq!(
3797 task.status,
3798 crate::store::TaskStatus::InProgress,
3799 "task must never be reclaimed while the dispatch guard is deterministically \
3800 still held, even under real cross-thread concurrency (dispatch_key={dispatch_key}, \
3801 iteration={i}, contains_key_now={}, map_len_now={})",
3802 dispatching.contains_key(&dispatch_key),
3803 dispatching.len(),
3804 );
3805 tokio::task::yield_now().await;
3806 }
3807
3808 let _ = release_tx.send(());
3810 holder.await.unwrap();
3811
3812 tick_recover_stuck_tasks(
3814 &backend,
3815 &tmux,
3816 "owner/repo",
3817 &task_manager,
3818 &config,
3819 &store,
3820 &std::collections::HashMap::new(),
3821 &cooled_review_router_for_test(),
3822 &dispatching,
3823 &Arc::new(DashSet::new()),
3824 )
3825 .await
3826 .unwrap();
3827 let task = store.get(id).await.unwrap();
3828 assert_eq!(
3829 task.status,
3830 crate::store::TaskStatus::New,
3831 "task should be reclaimed once the dispatch guard is released"
3832 );
3833 }
3834
3835 #[tokio::test]
3836 async fn recover_stuck_tasks_skips_external_in_review_waiting_for_human_review() {
3837 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3838 let mock = MockBackend::new();
3839 let status_updates = mock.status_updates.clone();
3840 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3841 let task_manager = Arc::new(TaskManager::with_store(
3842 backend.clone(),
3843 store.clone(),
3844 "owner/repo".to_string(),
3845 ));
3846 let tmux = Arc::new(TmuxManager::new());
3847 let config = EngineConfig {
3848 no_session_stuck_timeout: 600,
3849 stuck_timeout: 1800,
3850 ..EngineConfig::default()
3851 };
3852
3853 let id = store
3854 .upsert_external(&crate::store::UpsertExternal {
3855 repo: "owner/repo",
3856 ext_id: "101",
3857 title: "Waiting For Human Review",
3858 body: "",
3859 author: "",
3860 url: "",
3861 labels: &[],
3862 origin: "github",
3863 })
3864 .await
3865 .unwrap();
3866 store
3867 .update_status(id, crate::store::TaskStatus::InReview)
3868 .await
3869 .unwrap();
3870 set_task_updated_at_past(&store, id).await;
3871
3872 tick_recover_stuck_tasks(
3873 &backend,
3874 &tmux,
3875 "owner/repo",
3876 &task_manager,
3877 &config,
3878 &store,
3879 &std::collections::HashMap::new(),
3880 &cooled_review_router_for_test(),
3881 &Arc::new(DashMap::new()),
3882 &Arc::new(DashSet::new()),
3883 )
3884 .await
3885 .unwrap();
3886
3887 let updates = status_updates.lock().unwrap();
3888 assert!(
3889 updates.is_empty(),
3890 "InReview task waiting for human review should not be reset"
3891 );
3892 }
3893
3894 #[tokio::test]
3895 async fn unblock_parents_no_blocked_tasks() {
3896 let mock = MockBackend::new(); let status_updates = mock.status_updates.clone();
3898 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3899 let task_manager = make_task_manager(backend.clone());
3900 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3901
3902 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3903 .await
3904 .unwrap();
3905
3906 assert!(status_updates.lock().unwrap().is_empty());
3907 }
3908
3909 #[tokio::test]
3910 async fn unblock_parents_handles_multiple_blocked_tasks() {
3911 let mut mock = MockBackend::new();
3912
3913 let t40 = make_task("40", &["status:blocked"]);
3915 mock.blocked_tasks.push(t40);
3916 mock.sub_issues
3917 .insert("40".to_string(), vec![ExternalId("41".to_string())]);
3918 mock.tasks_by_id
3919 .insert("41".to_string(), make_task("41", &["status:done"]));
3920
3921 let t50 = make_task("50", &["status:blocked"]);
3923 mock.blocked_tasks.push(t50);
3924 mock.sub_issues
3925 .insert("50".to_string(), vec![ExternalId("51".to_string())]);
3926 mock.tasks_by_id
3927 .insert("51".to_string(), make_task("51", &["status:in_progress"]));
3928
3929 let status_updates = mock.status_updates.clone();
3930 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3931 let task_manager = make_task_manager(backend.clone());
3932 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3933
3934 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3935 .await
3936 .unwrap();
3937
3938 let updates = status_updates.lock().unwrap();
3939 assert_eq!(updates.len(), 1, "only one parent should be unblocked");
3940 assert_eq!(updates[0].0, "40", "task 40 should be unblocked");
3941 assert_eq!(updates[0].1, Status::New);
3942 }
3943
3944 #[tokio::test]
3945 async fn detect_silent_agents_skips_backend_for_internal_tasks() {
3946 let transport = Arc::new(Transport::new());
3947 let capture = Arc::new(CaptureService::new(transport));
3948 let mock = Arc::new(MockBackend::new());
3949 let backend: Arc<dyn ExternalBackend> = mock.clone();
3950 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3951 let task_manager = Arc::new(TaskManager::with_store(
3952 backend.clone(),
3953 store.clone(),
3954 "owner/repo".to_string(),
3955 ));
3956 let tmux = Arc::new(TmuxManager::new());
3957 let config = EngineConfig {
3958 silence_grace_period: 0,
3959 ..EngineConfig::default()
3960 };
3961
3962 let internal_id = store
3963 .create_internal("owner/repo", "Silent internal", "", "cron", "1", None)
3964 .await
3965 .unwrap();
3966 store
3967 .update_status(internal_id, crate::store::TaskStatus::InProgress)
3968 .await
3969 .unwrap();
3970
3971 let task_id = format!("internal:{internal_id}");
3972 capture
3973 .register_session("owner/repo", &task_id, "orch-test-internal")
3974 .await;
3975 tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
3976
3977 tick_detect_silent_agents(
3978 &tmux,
3979 "owner/repo",
3980 &capture,
3981 &backend,
3982 &task_manager,
3983 &config,
3984 &store,
3985 )
3986 .await
3987 .unwrap();
3988
3989 assert!(
3990 mock.removed_labels.lock().unwrap().is_empty(),
3991 "internal tasks should skip backend label removal"
3992 );
3993 assert!(
3994 mock.posted_comments.lock().unwrap().is_empty(),
3995 "internal tasks should skip backend comments"
3996 );
3997
3998 let task = store.get(internal_id).await.unwrap();
3999 assert!(
4003 matches!(
4004 task.status,
4005 crate::store::TaskStatus::Routed | crate::store::TaskStatus::New
4006 ),
4007 "internal task should be routed to fallback or reset to new, got {:?}",
4008 task.status
4009 );
4010 }
4011
4012 #[tokio::test]
4013 async fn detect_silent_agents_finalizes_open_task_runs_rows() {
4014 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4015 let mock = MockBackend::new();
4016 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4017 let task_manager = Arc::new(TaskManager::with_store(
4018 backend.clone(),
4019 store.clone(),
4020 "owner/repo".to_string(),
4021 ));
4022 let transport = Arc::new(Transport::new());
4023 let capture = Arc::new(CaptureService::new(transport));
4024 let tmux = Arc::new(TmuxManager::new());
4025 let config = EngineConfig {
4026 silence_grace_period: 60,
4027 silence_cooldown: 300,
4028 ..EngineConfig::default()
4029 };
4030
4031 let internal_id = store
4032 .create_internal("owner/repo", "Silent with open run", "", "cron", "1", None)
4033 .await
4034 .unwrap();
4035 store
4036 .set_fields(
4037 internal_id,
4038 &[
4039 ("agent", serde_json::json!("claude")),
4040 ("model", serde_json::json!("sonnet")),
4041 ],
4042 )
4043 .await
4044 .unwrap();
4045 store
4046 .update_status(internal_id, crate::store::TaskStatus::InProgress)
4047 .await
4048 .unwrap();
4049
4050 let task_id = format!("internal:{internal_id}");
4051 capture
4052 .register_session("owner/repo", &task_id, "orch-test-silent-run")
4053 .await;
4054 capture
4055 .set_buffer_state_for_test(
4056 "owner/repo",
4057 &task_id,
4058 false,
4059 false,
4060 chrono::Utc::now() - chrono::Duration::seconds(120),
4061 )
4062 .await;
4063
4064 store
4066 .start_run(&crate::store::StartRun {
4067 task_id: internal_id,
4068 attempt: 1,
4069 run_type: "agent",
4070 agent: "claude",
4071 model: "sonnet",
4072 command: "claude --model sonnet",
4073 prompt: "fix it",
4074 })
4075 .await
4076 .unwrap();
4077
4078 tick_detect_silent_agents(
4079 &tmux,
4080 "owner/repo",
4081 &capture,
4082 &backend,
4083 &task_manager,
4084 &config,
4085 &store,
4086 )
4087 .await
4088 .unwrap();
4089
4090 let runs = store.get_runs(internal_id).await.unwrap();
4091 assert_eq!(runs.len(), 1);
4092 assert_eq!(runs[0].outcome, "aborted");
4093 assert!(runs[0].completed_at.is_some());
4094 assert_eq!(runs[0].error, "silence detection: session killed");
4095 }
4096
4097 #[tokio::test]
4108 async fn phase1_session_completion_touch_prevents_phase2_reclaim() {
4109 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4110 let mock = MockBackend::new();
4111 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4112 let task_manager = Arc::new(TaskManager::with_store(
4113 backend.clone(),
4114 store.clone(),
4115 "owner/repo".to_string(),
4116 ));
4117 let tmux = Arc::new(TmuxManager::new());
4118 let config = EngineConfig {
4119 no_session_stuck_timeout: 600,
4120 stuck_timeout: 1800,
4121 ..EngineConfig::default()
4122 };
4123
4124 let id = store
4128 .create_internal("owner/repo", "Completed task", "", "cron", "1", None)
4129 .await
4130 .unwrap();
4131 store
4132 .update_status(id, crate::store::TaskStatus::InProgress)
4133 .await
4134 .unwrap();
4135 set_task_updated_at_past(&store, id).await;
4136
4137 let task_id = format!("internal:{id}");
4140 store_touch_updated_at(&Some(Arc::clone(&store)), "owner/repo", &task_id).await;
4141
4142 tick_recover_stuck_tasks(
4144 &backend,
4145 &tmux,
4146 "owner/repo",
4147 &task_manager,
4148 &config,
4149 &store,
4150 &std::collections::HashMap::new(),
4151 &cooled_review_router_for_test(),
4152 &Arc::new(DashMap::new()),
4153 &Arc::new(DashSet::new()),
4154 )
4155 .await
4156 .unwrap();
4157
4158 let task = store.get(id).await.unwrap();
4159 assert_eq!(
4160 task.status,
4161 crate::store::TaskStatus::InProgress,
4162 "Phase 2 must not reclaim a task whose updated_at was just touched by Phase 1"
4163 );
4164 }
4165
4166 #[tokio::test]
4167 async fn stuck_task_timing_treats_dead_session_as_missing() {
4168 let tmux = Arc::new(TmuxManager::new());
4169 let repo = "owner/repo";
4170 let task_id = "internal:33498";
4171 let session_name = tmux.session_name(repo, task_id);
4172
4173 let create_result = tokio::process::Command::new("tmux")
4174 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
4175 .output()
4176 .await;
4177
4178 match create_result {
4179 Ok(o) if o.status.success() => {}
4180 _ => {
4181 eprintln!("Skipping test: tmux not available or failed to create test session");
4182 return;
4183 }
4184 }
4185
4186 let _guard = SessionGuard(session_name.clone());
4187
4188 let set_option_result = tokio::process::Command::new("tmux")
4189 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
4190 .output()
4191 .await;
4192 if !matches!(set_option_result, Ok(ref o) if o.status.success()) {
4193 eprintln!("Skipping test: unable to set tmux remain-on-exit option");
4194 return;
4195 }
4196
4197 let send_exit_result = tokio::process::Command::new("tmux")
4198 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
4199 .output()
4200 .await;
4201 if !matches!(send_exit_result, Ok(ref o) if o.status.success()) {
4202 eprintln!("Skipping test: unable to exit tmux pane");
4203 return;
4204 }
4205
4206 for _ in 0..20 {
4208 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4209 if !tmux.session_is_running(&session_name).await {
4210 break;
4211 }
4212 }
4213
4214 let mut session_map = tmux.batch_session_active().await;
4219 for _ in 0..20 {
4220 if !session_map.get(&session_name).copied().unwrap_or(false) {
4221 break;
4222 }
4223 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4224 session_map = tmux.batch_session_active().await;
4225 }
4226 if session_map.get(&session_name).copied().unwrap_or(false) {
4227 eprintln!("Skipping test: session still alive in batch_session_active after retries (CI tmux lag)");
4228 return;
4229 }
4230
4231 let config = EngineConfig {
4232 no_session_stuck_timeout: 600,
4233 stuck_timeout: 1800,
4234 ..EngineConfig::default()
4235 };
4236 let updated_at = (chrono::Utc::now()
4237 - chrono::Duration::seconds(config.no_session_stuck_timeout as i64 + 5))
4238 .to_rfc3339();
4239
4240 let timing = stuck_task_timing_from_map(
4241 &tmux,
4242 repo,
4243 task_id,
4244 &updated_at,
4245 &config,
4246 "parse failure",
4247 &session_map,
4248 )
4249 .expect("dead session should be treated as missing");
4250
4251 assert!(!timing.has_session);
4252 assert_eq!(timing.threshold, config.no_session_stuck_timeout);
4253 }
4254
4255 #[tokio::test]
4256 #[serial_test::serial(suspend_state)]
4257 async fn stuck_task_timing_discounts_host_suspend_gap() {
4258 super::super::suspend::clear_for_test();
4259
4260 let tmux = Arc::new(TmuxManager::new());
4261 let repo = "owner/repo";
4262 let task_id = "internal:99001";
4263 let session_map = std::collections::HashMap::new();
4264
4265 let config = EngineConfig {
4266 no_session_stuck_timeout: 1800,
4267 stuck_timeout: 1800,
4268 ..EngineConfig::default()
4269 };
4270
4271 let updated_at = (chrono::Utc::now() - chrono::Duration::minutes(33)).to_rfc3339();
4275 let suspend_gap_detected_at = chrono::Utc::now() - chrono::Duration::minutes(1);
4276 super::super::suspend::inject_gap_for_test(
4277 suspend_gap_detected_at,
4278 chrono::Duration::minutes(30),
4279 );
4280
4281 let timing = stuck_task_timing_from_map(
4282 &tmux,
4283 repo,
4284 task_id,
4285 &updated_at,
4286 &config,
4287 "parse failure",
4288 &session_map,
4289 );
4290
4291 assert!(
4292 timing.is_none(),
4293 "task age should be discounted below the stuck threshold once suspend time is subtracted, got {timing:?}"
4294 );
4295
4296 super::super::suspend::clear_for_test();
4297 }
4298
4299 #[tokio::test]
4300 async fn recover_stuck_tasks_reclaims_routed_task_blocked_by_stale_session() {
4301 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4302 let mock = MockBackend::new();
4303 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4304 let task_manager = Arc::new(TaskManager::with_store(
4305 backend.clone(),
4306 store.clone(),
4307 "owner/repo".to_string(),
4308 ));
4309 let tmux = Arc::new(TmuxManager::new());
4310 let config = EngineConfig {
4311 no_session_stuck_timeout: 600,
4312 stuck_timeout: 1,
4313 ..EngineConfig::default()
4314 };
4315
4316 let id = store
4317 .create_internal("owner/repo", "Blocked routed task", "", "cron", "1", None)
4318 .await
4319 .unwrap();
4320 store
4321 .update_status(id, crate::store::TaskStatus::Routed)
4322 .await
4323 .unwrap();
4324 set_task_updated_at_past(&store, id).await;
4325
4326 let task_id = format!("internal:{id}");
4327 let session_name = tmux.session_name("owner/repo", &task_id);
4328 let create_result = tokio::process::Command::new("tmux")
4329 .args([
4330 "new-session",
4331 "-d",
4332 "-s",
4333 &session_name,
4334 "-c",
4335 "/tmp",
4336 "sleep 60",
4337 ])
4338 .output()
4339 .await;
4340
4341 match create_result {
4342 Ok(o) if o.status.success() => {}
4343 _ => {
4344 eprintln!("Skipping test: tmux not available or failed to create test session");
4345 return;
4346 }
4347 }
4348
4349 let session_map = tmux.batch_session_active().await;
4350 assert_eq!(session_map.get(&session_name).copied(), Some(true));
4351
4352 tick_recover_stuck_tasks(
4353 &backend,
4354 &tmux,
4355 "owner/repo",
4356 &task_manager,
4357 &config,
4358 &store,
4359 &session_map,
4360 &cooled_review_router_for_test(),
4361 &Arc::new(DashMap::new()),
4362 &Arc::new(DashSet::new()),
4363 )
4364 .await
4365 .unwrap();
4366
4367 let task = store.get(id).await.unwrap();
4368 assert_eq!(
4369 task.status,
4370 crate::store::TaskStatus::New,
4371 "routed task blocked by a stale session should be reset for re-routing"
4372 );
4373 assert!(
4374 !tmux.session_exists(&session_name).await,
4375 "recovery should kill the stale tmux session so dispatch can proceed"
4376 );
4377 }
4378
4379 #[tokio::test]
4393 async fn dispatch_does_not_deadlock_under_write_lock() {
4394 use crate::engine::router::Router;
4395 use tokio::sync::RwLock;
4396
4397 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4398 let mock = MockBackend::new();
4399 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4400 let task_manager = Arc::new(TaskManager::with_store(
4401 backend.clone(),
4402 store.clone(),
4403 "owner/repo".to_string(),
4404 ));
4405 let tmux = Arc::new(TmuxManager::new());
4406 let semaphore = Arc::new(Semaphore::new(4));
4407 let (weight_tx, _weight_rx) = mpsc::channel(16);
4408 let dispatching = Arc::new(DashMap::new());
4409 let transport = Arc::new(Transport::new());
4410 let capture = Arc::new(CaptureService::new(transport));
4411
4412 let router_config = crate::engine::router::RouterConfig::default();
4413 let router = Router::new(router_config);
4414 let router_arc = Arc::new(RwLock::new(router));
4415
4416 let write_guard = router_arc.write().await;
4420 let dispatch_mode = dispatch_mode_from_router(&write_guard);
4421
4422 let result = tokio::time::timeout(
4423 std::time::Duration::from_secs(2),
4424 tick_dispatch_tasks(
4425 &backend,
4426 &tmux,
4427 "owner/repo",
4428 &Arc::new(crate::engine::runner::TaskRunner::new(
4429 "owner/repo".to_string(),
4430 )),
4431 &capture,
4432 &semaphore,
4433 &task_manager,
4434 &weight_tx,
4435 dispatch_mode,
4436 &dispatching,
4437 &store,
4438 &std::collections::HashMap::new(),
4439 ),
4440 )
4441 .await;
4442
4443 assert!(
4444 result.is_ok(),
4445 "tick_dispatch_tasks deadlocked! It tried to acquire a read lock \
4446 on router_arc while a write lock was already held (issue #1361)"
4447 );
4448 }
4449
4450 #[tokio::test]
4458 async fn dispatch_skips_task_whose_routed_model_entered_cooldown() {
4459 use crate::store::{StoreRoute, TaskStatus as DbStatus, UpsertExternal};
4460
4461 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4462 let mock = MockBackend::new();
4463 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4464 let task_manager = Arc::new(TaskManager::with_store(
4465 backend.clone(),
4466 store.clone(),
4467 "owner/repo".to_string(),
4468 ));
4469 let tmux = Arc::new(TmuxManager::new());
4470 let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
4471 let semaphore = Arc::new(Semaphore::new(4));
4472 let (weight_tx, _weight_rx) = mpsc::channel(16);
4473 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4474 let transport = Arc::new(Transport::new());
4475 let capture = Arc::new(CaptureService::new(transport));
4476
4477 let id = store
4478 .upsert_external(&UpsertExternal {
4479 repo: "owner/repo",
4480 ext_id: "999",
4481 title: "Stale routed task",
4482 body: "",
4483 author: "user",
4484 url: "https://github.com/owner/repo/issues/999",
4485 labels: &[],
4486 origin: "github",
4487 })
4488 .await
4489 .unwrap();
4490 store
4491 .store_route(&StoreRoute {
4492 id,
4493 agent: "minimax",
4494 model: Some("opus"),
4495 complexity: "medium",
4496 estimate: 0,
4497 reason: "test",
4498 profile: "{}",
4499 skills: "[]",
4500 })
4501 .await
4502 .unwrap();
4503 store.update_status(id, DbStatus::Routed).await.unwrap();
4504
4505 crate::engine::cooldown::set_model_cooldown(
4508 "minimax",
4509 "opus",
4510 300,
4511 "rate_limit_usage_window",
4512 )
4513 .await;
4514
4515 let dispatch_mode = DispatchMode {
4516 is_degraded: false,
4517 healthy_agents: 1,
4518 threshold: 1,
4519 };
4520
4521 tick_dispatch_tasks(
4522 &backend,
4523 &tmux,
4524 "owner/repo",
4525 &runner,
4526 &capture,
4527 &semaphore,
4528 &task_manager,
4529 &weight_tx,
4530 dispatch_mode,
4531 &dispatching,
4532 &store,
4533 &std::collections::HashMap::new(),
4534 )
4535 .await
4536 .unwrap();
4537
4538 let task = store.get(id).await.unwrap();
4539 assert_eq!(
4540 task.status,
4541 DbStatus::Routed,
4542 "task whose routed model is now cooled down must stay Routed, not be dispatched"
4543 );
4544 assert!(
4545 dispatching.is_empty(),
4546 "dispatch guard must be released when skipping a stale-cooldown task"
4547 );
4548 }
4549
4550 #[tokio::test]
4551 async fn dispatch_skips_a_task_with_an_open_dependency() {
4552 use crate::store::{
4553 DepSource, NewDependency, StoreRoute, TaskStatus as DbStatus, UpsertExternal,
4554 };
4555
4556 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4557 let mock = MockBackend::new();
4558 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4559 let task_manager = Arc::new(TaskManager::with_store(
4560 backend.clone(),
4561 store.clone(),
4562 "owner/repo".to_string(),
4563 ));
4564 let tmux = Arc::new(TmuxManager::new());
4565 let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
4566 let semaphore = Arc::new(Semaphore::new(4));
4567 let (weight_tx, _weight_rx) = mpsc::channel(16);
4568 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4569 let transport = Arc::new(Transport::new());
4570 let capture = Arc::new(CaptureService::new(transport));
4571
4572 let id = store
4573 .upsert_external(&UpsertExternal {
4574 repo: "owner/repo",
4575 ext_id: "42",
4576 title: "Waiting routed task",
4577 body: "",
4578 author: "user",
4579 url: "https://github.com/owner/repo/issues/42",
4580 labels: &[],
4581 origin: "github",
4582 })
4583 .await
4584 .unwrap();
4585 store
4586 .replace_dependencies(
4587 id,
4588 &[NewDependency {
4589 repo: "owner/repo".into(),
4590 number: 7,
4591 source: DepSource::Github,
4592 }],
4593 )
4594 .await
4595 .unwrap();
4596 store
4597 .store_route(&StoreRoute {
4598 id,
4599 agent: "claude",
4600 model: Some("haiku"),
4601 complexity: "simple",
4602 estimate: 0,
4603 reason: "test",
4604 profile: "{}",
4605 skills: "[]",
4606 })
4607 .await
4608 .unwrap();
4609 store.update_status(id, DbStatus::Routed).await.unwrap();
4610
4611 let dispatch_mode = DispatchMode {
4612 is_degraded: false,
4613 healthy_agents: 1,
4614 threshold: 1,
4615 };
4616 tick_dispatch_tasks(
4617 &backend,
4618 &tmux,
4619 "owner/repo",
4620 &runner,
4621 &capture,
4622 &semaphore,
4623 &task_manager,
4624 &weight_tx,
4625 dispatch_mode,
4626 &dispatching,
4627 &store,
4628 &std::collections::HashMap::new(),
4629 )
4630 .await
4631 .unwrap();
4632
4633 let task = store.get(id).await.unwrap();
4634 assert_eq!(task.status, DbStatus::Waiting);
4635 assert!(dispatching.is_empty());
4636 }
4637
4638 #[tokio::test]
4651 async fn completed_session_does_not_cause_stuck_reclaim_race() {
4652 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4653 let mock = MockBackend::new();
4654 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4655 let task_manager = Arc::new(TaskManager::with_store(
4656 backend.clone(),
4657 store.clone(),
4658 "owner/repo".to_string(),
4659 ));
4660 let tmux = Arc::new(TmuxManager::new());
4661 let transport = Arc::new(Transport::new());
4662 let capture = Arc::new(CaptureService::new(transport));
4663
4664 let id = store
4666 .upsert_external(&crate::store::UpsertExternal {
4667 repo: "owner/repo",
4668 ext_id: "200",
4669 title: "Race Task",
4670 body: "",
4671 author: "",
4672 url: "",
4673 labels: &[],
4674 origin: "github",
4675 })
4676 .await
4677 .unwrap();
4678 store
4679 .update_status(id, crate::store::TaskStatus::InProgress)
4680 .await
4681 .unwrap();
4682
4683 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4685 .bind(id)
4686 .execute(store.pool())
4687 .await
4688 .unwrap();
4689
4690 let task_id = "200";
4692 let session_name = tmux.session_name("owner/repo", task_id);
4693 let create_result = tokio::process::Command::new("tmux")
4694 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
4695 .output()
4696 .await;
4697 match create_result {
4698 Ok(o) if o.status.success() => {}
4699 _ => {
4700 eprintln!("Skipping test: tmux not available or failed to create test session");
4701 return;
4702 }
4703 }
4704 let _guard = SessionGuard(session_name.clone());
4705
4706 let _ = tokio::process::Command::new("tmux")
4708 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
4709 .output()
4710 .await;
4711 let _ = tokio::process::Command::new("tmux")
4712 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
4713 .output()
4714 .await;
4715
4716 for _ in 0..20 {
4718 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4719 if !tmux.session_is_running(&session_name).await {
4720 break;
4721 }
4722 }
4723
4724 let task_key = task_id.to_string();
4726 capture
4727 .register_session("owner/repo", &task_key, &session_name)
4728 .await;
4729
4730 let config = EngineConfig {
4732 no_session_stuck_timeout: 600,
4733 stuck_timeout: 1800,
4734 ..EngineConfig::default()
4735 };
4736
4737 tick_check_session_completions(&tmux, "owner/repo", &capture, &store)
4738 .await
4739 .unwrap();
4740
4741 {
4747 let task_state = store.get(id).await.unwrap();
4748 if task_state.updated_at.starts_with("2020") {
4749 eprintln!(
4750 "Skipping test: tick_check_session_completions did not update stored \
4751 updated_at — session was not in snapshot (CI tmux environment issue)"
4752 );
4753 return;
4754 }
4755 }
4756
4757 tick_recover_stuck_tasks(
4759 &backend,
4760 &tmux,
4761 "owner/repo",
4762 &task_manager,
4763 &config,
4764 &store,
4765 &std::collections::HashMap::new(),
4766 &cooled_review_router_for_test(),
4767 &Arc::new(DashMap::new()),
4768 &Arc::new(DashSet::new()),
4769 )
4770 .await
4771 .unwrap();
4772
4773 let task = store.get(id).await.unwrap();
4775 assert_eq!(
4776 task.status,
4777 crate::store::TaskStatus::InProgress,
4778 "task should not be reclaimed to New"
4779 );
4780 }
4781
4782 #[tokio::test]
4792 async fn session_completion_matches_repo_configured_with_git_suffix() {
4793 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4794 let tmux = Arc::new(TmuxManager::new());
4795 let transport = Arc::new(Transport::new());
4796 let capture = Arc::new(CaptureService::new(transport));
4797
4798 let repo = "owner/repo.git";
4799 let id = store
4800 .upsert_external(&crate::store::UpsertExternal {
4801 repo,
4802 ext_id: "201",
4803 title: "Git-suffix repo task",
4804 body: "",
4805 author: "",
4806 url: "",
4807 labels: &[],
4808 origin: "github",
4809 })
4810 .await
4811 .unwrap();
4812 store
4813 .update_status(id, crate::store::TaskStatus::InProgress)
4814 .await
4815 .unwrap();
4816 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4817 .bind(id)
4818 .execute(store.pool())
4819 .await
4820 .unwrap();
4821
4822 let task_id = "201";
4825 let session_name = tmux.session_name(repo, task_id);
4826 let create_result = tokio::process::Command::new("tmux")
4827 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
4828 .output()
4829 .await;
4830 match create_result {
4831 Ok(o) if o.status.success() => {}
4832 _ => {
4833 eprintln!("Skipping test: tmux not available or failed to create test session");
4834 return;
4835 }
4836 }
4837 let _guard = SessionGuard(session_name.clone());
4838
4839 let _ = tokio::process::Command::new("tmux")
4840 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
4841 .output()
4842 .await;
4843 let _ = tokio::process::Command::new("tmux")
4844 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
4845 .output()
4846 .await;
4847
4848 for _ in 0..20 {
4849 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4850 if !tmux.session_is_running(&session_name).await {
4851 break;
4852 }
4853 }
4854
4855 capture.register_session(repo, task_id, &session_name).await;
4856
4857 tick_check_session_completions(&tmux, repo, &capture, &store)
4858 .await
4859 .unwrap();
4860
4861 let task_state = store.get(id).await.unwrap();
4862 if task_state.updated_at.starts_with("2020") {
4863 eprintln!(
4864 "Skipping test: tick_check_session_completions did not update stored \
4865 updated_at — session was not in snapshot (CI tmux environment issue)"
4866 );
4867 return;
4868 }
4869 assert!(
4870 !task_state.updated_at.starts_with("2020"),
4871 "tick_check_session_completions must match its own repo's live sessions \
4872 even when the repo is configured with a \".git\" suffix"
4873 );
4874 }
4875
4876 #[tokio::test]
4882 async fn job_scheduler_returns_ok_on_duplicate_job_config() {
4883 let tmp = tempfile::tempdir().unwrap();
4884 let cfg = tmp.path().join(".orch.yml");
4885 std::fs::write(
4886 &cfg,
4887 "jobs:\n - id: trading-scan\n schedule: \"0 9 * * *\"\n task:\n title: Scan\n body: do it\n",
4888 )
4889 .unwrap();
4890 let jobs_dir = tmp.path().join("prompts").join("jobs");
4891 std::fs::create_dir_all(&jobs_dir).unwrap();
4892 std::fs::write(
4893 jobs_dir.join("trading-scan.md"),
4894 "---\nid: trading-scan\nschedule: '0 9 * * *'\ntitle: Trading Scan\n---\n\nDo trading scan.\n",
4895 )
4896 .unwrap();
4897
4898 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4899 let result = tick_job_scheduler(&cfg, &backend, None, "test/repo", None).await;
4900 assert!(
4901 result.is_ok(),
4902 "duplicate job id must not propagate as Err: {result:?}"
4903 );
4904 }
4905
4906 #[tokio::test]
4910 async fn job_scheduler_processes_valid_project_when_other_has_duplicate() {
4911 let bad_tmp = tempfile::tempdir().unwrap();
4913 let bad_cfg = bad_tmp.path().join(".orch.yml");
4914 std::fs::write(
4915 &bad_cfg,
4916 "jobs:\n - id: dup\n schedule: \"0 9 * * *\"\n task:\n title: Dup\n body: x\n",
4917 )
4918 .unwrap();
4919 let bad_jobs_dir = bad_tmp.path().join("prompts").join("jobs");
4920 std::fs::create_dir_all(&bad_jobs_dir).unwrap();
4921 std::fs::write(
4922 bad_jobs_dir.join("dup.md"),
4923 "---\nid: dup\nschedule: '0 9 * * *'\ntitle: Dup\n---\n\nbody\n",
4924 )
4925 .unwrap();
4926
4927 let good_tmp = tempfile::tempdir().unwrap();
4929 let good_cfg = good_tmp.path().join(".orch.yml");
4930 std::fs::write(
4931 &good_cfg,
4932 "jobs:\n - id: valid-job\n schedule: \"0 9 * * *\"\n task:\n title: Valid\n body: ok\n",
4933 )
4934 .unwrap();
4935
4936 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4937
4938 let bad_result = tick_job_scheduler(&bad_cfg, &backend, None, "test/bad-repo", None).await;
4939 assert!(
4940 bad_result.is_ok(),
4941 "bad project must not abort scheduler: {bad_result:?}"
4942 );
4943
4944 let good_result =
4945 tick_job_scheduler(&good_cfg, &backend, None, "test/good-repo", None).await;
4946 assert!(
4947 good_result.is_ok(),
4948 "valid project must still run after bad project: {good_result:?}"
4949 );
4950 }
4951
4952 #[serial_test::serial(cooldown_state)]
4960 #[tokio::test]
4961 async fn global_sweep_routes_inactive_repo_tasks() {
4962 use crate::engine::router::{Router, RouterConfig};
4963 use crate::store::{TaskStatus, UpsertExternal};
4964
4965 crate::engine::cooldown::reset_global_state().await;
4966
4967 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4968
4969 let active_id = store
4971 .upsert_external(&UpsertExternal {
4972 repo: "active/repo",
4973 ext_id: "1",
4974 title: "Active task",
4975 body: "",
4976 author: "user",
4977 url: "https://github.com/active/repo/issues/1",
4978 labels: &[],
4979 origin: "github",
4980 })
4981 .await
4982 .unwrap();
4983
4984 let inactive_id = store
4986 .upsert_external(&UpsertExternal {
4987 repo: "inactive/repo",
4988 ext_id: "42",
4989 title: "Orphaned task",
4990 body: "",
4991 author: "user",
4992 url: "https://github.com/inactive/repo/issues/42",
4993 labels: &[],
4994 origin: "github",
4995 })
4996 .await
4997 .unwrap();
4998
4999 assert_eq!(store.get(active_id).await.unwrap().status, TaskStatus::New);
5000 assert_eq!(
5001 store.get(inactive_id).await.unwrap().status,
5002 TaskStatus::New
5003 );
5004
5005 let mut model_map: std::collections::HashMap<
5006 String,
5007 std::collections::HashMap<String, Vec<String>>,
5008 > = std::collections::HashMap::new();
5009 let mut tier = std::collections::HashMap::new();
5010 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5011 model_map.insert("medium".to_string(), tier);
5012 let config = RouterConfig {
5013 mode: "round_robin".to_string(),
5014 agents: vec!["claude".to_string()],
5015 model_map,
5016 ..Default::default()
5017 };
5018 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5019
5020 let active_repos: std::collections::HashSet<String> =
5021 ["active/repo".to_string()].into_iter().collect();
5022 let mut routing_quota = RoutingTickQuota::new(1);
5023 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5024 .await
5025 .unwrap();
5026
5027 assert_eq!(
5029 store.get(active_id).await.unwrap().status,
5030 TaskStatus::New,
5031 "global sweep must not touch tasks for the active repo"
5032 );
5033
5034 let after = store.get(inactive_id).await.unwrap();
5036 assert_eq!(
5037 after.status,
5038 TaskStatus::Routed,
5039 "global sweep must route tasks from inactive repos"
5040 );
5041 assert!(
5042 matches!(&after.agent, Some(a) if !a.is_empty()),
5043 "agent must be set after routing"
5044 );
5045 }
5046
5047 #[serial_test::serial(cooldown_state)]
5055 #[tokio::test]
5056 async fn global_sweep_does_not_touch_other_active_repo_tasks() {
5057 use crate::engine::router::{Router, RouterConfig};
5058 use crate::store::{TaskStatus, UpsertExternal};
5059
5060 crate::engine::cooldown::reset_global_state().await;
5061
5062 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5063
5064 let other_active_id = store
5067 .upsert_external(&UpsertExternal {
5068 repo: "other-active/repo",
5069 ext_id: "7",
5070 title: "Other active project's task",
5071 body: "",
5072 author: "user",
5073 url: "https://github.com/other-active/repo/issues/7",
5074 labels: &[],
5075 origin: "github",
5076 })
5077 .await
5078 .unwrap();
5079
5080 let inactive_id = store
5082 .upsert_external(&UpsertExternal {
5083 repo: "inactive/repo",
5084 ext_id: "42",
5085 title: "Orphaned task",
5086 body: "",
5087 author: "user",
5088 url: "https://github.com/inactive/repo/issues/42",
5089 labels: &[],
5090 origin: "github",
5091 })
5092 .await
5093 .unwrap();
5094
5095 let mut model_map: std::collections::HashMap<
5096 String,
5097 std::collections::HashMap<String, Vec<String>>,
5098 > = std::collections::HashMap::new();
5099 let mut tier = std::collections::HashMap::new();
5100 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5101 model_map.insert("medium".to_string(), tier);
5102 let config = RouterConfig {
5103 mode: "round_robin".to_string(),
5104 agents: vec!["claude".to_string()],
5105 model_map,
5106 ..Default::default()
5107 };
5108 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5109
5110 let active_repos: std::collections::HashSet<String> =
5114 ["active/repo".to_string(), "other-active/repo".to_string()]
5115 .into_iter()
5116 .collect();
5117 let mut routing_quota = RoutingTickQuota::new(1);
5118 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5119 .await
5120 .unwrap();
5121
5122 assert_eq!(
5123 store.get(other_active_id).await.unwrap().status,
5124 TaskStatus::New,
5125 "global sweep run from a different active repo's tick must not route \
5126 another active repo's new tasks — that repo's own tick will route them"
5127 );
5128 assert_eq!(
5129 store.get(inactive_id).await.unwrap().status,
5130 TaskStatus::Routed,
5131 "global sweep must still route tasks from genuinely orphaned repos"
5132 );
5133 }
5134
5135 #[serial_test::serial(cooldown_state)]
5137 #[tokio::test]
5138 async fn global_sweep_skips_no_agent_tasks() {
5139 use crate::engine::router::{Router, RouterConfig};
5140 use crate::store::{TaskStatus, UpsertExternal};
5141
5142 crate::engine::cooldown::reset_global_state().await;
5143
5144 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5145
5146 let id = store
5147 .upsert_external(&UpsertExternal {
5148 repo: "inactive/repo",
5149 ext_id: "99",
5150 title: "No-agent task",
5151 body: "",
5152 author: "user",
5153 url: "https://github.com/inactive/repo/issues/99",
5154 labels: &["no-agent".to_string()],
5155 origin: "github",
5156 })
5157 .await
5158 .unwrap();
5159
5160 let mut model_map: std::collections::HashMap<
5161 String,
5162 std::collections::HashMap<String, Vec<String>>,
5163 > = std::collections::HashMap::new();
5164 let mut tier = std::collections::HashMap::new();
5165 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5166 model_map.insert("medium".to_string(), tier);
5167 let config = RouterConfig {
5168 mode: "round_robin".to_string(),
5169 agents: vec!["claude".to_string()],
5170 model_map,
5171 ..Default::default()
5172 };
5173 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5174
5175 let active_repos: std::collections::HashSet<String> =
5176 ["active/repo".to_string()].into_iter().collect();
5177 let mut routing_quota = RoutingTickQuota::new(1);
5178 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5179 .await
5180 .unwrap();
5181
5182 assert_eq!(
5183 store.get(id).await.unwrap().status,
5184 TaskStatus::New,
5185 "no-agent tasks must not be routed"
5186 );
5187 }
5188
5189 #[serial_test::serial(cooldown_state)]
5194 #[tokio::test]
5195 async fn global_sweep_honors_shared_routing_quota() {
5196 use crate::engine::router::{Router, RouterConfig};
5197 use crate::store::{TaskStatus, UpsertExternal};
5198
5199 crate::engine::cooldown::reset_global_state().await;
5200
5201 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5202 for ext_id in ["1", "2"] {
5203 store
5204 .upsert_external(&UpsertExternal {
5205 repo: "inactive/repo",
5206 ext_id,
5207 title: "Orphaned task",
5208 body: "",
5209 author: "user",
5210 url: &format!("https://github.com/inactive/repo/issues/{ext_id}"),
5211 labels: &[],
5212 origin: "github",
5213 })
5214 .await
5215 .unwrap();
5216 }
5217
5218 let mut model_map: std::collections::HashMap<
5219 String,
5220 std::collections::HashMap<String, Vec<String>>,
5221 > = std::collections::HashMap::new();
5222 let mut tier = std::collections::HashMap::new();
5223 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5224 model_map.insert("medium".to_string(), tier);
5225 let config = RouterConfig {
5226 mode: "round_robin".to_string(),
5227 agents: vec!["claude".to_string()],
5228 model_map,
5229 ..Default::default()
5230 };
5231 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5232
5233 let active_repos: std::collections::HashSet<String> =
5234 ["active/repo".to_string()].into_iter().collect();
5235 let mut routing_quota = RoutingTickQuota::new(1);
5236 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5237 .await
5238 .unwrap();
5239
5240 let all_tasks = store.list_all_global().await.unwrap();
5241 let routed = all_tasks
5242 .iter()
5243 .filter(|task| task.status == TaskStatus::Routed)
5244 .count();
5245 let still_new = all_tasks
5246 .iter()
5247 .filter(|task| task.status == TaskStatus::New)
5248 .count();
5249
5250 assert_eq!(routed, 1, "only one task may consume the shared quota");
5251 assert_eq!(still_new, 1, "remaining tasks wait for the next tick");
5252
5253 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5254 .await
5255 .unwrap();
5256 let after_exhausted = store.list_all_global().await.unwrap();
5257 assert_eq!(
5258 after_exhausted
5259 .iter()
5260 .filter(|task| task.status == TaskStatus::Routed)
5261 .count(),
5262 1,
5263 "an exhausted shared quota must not reset within the same tick"
5264 );
5265 }
5266
5267 #[tokio::test]
5284 async fn group_orphaned_routed_tasks_selects_inactive_repo_tasks() {
5285 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
5286
5287 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5288
5289 async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
5290 let id = store
5291 .upsert_external(&UpsertExternal {
5292 repo,
5293 ext_id,
5294 title: "Routed task",
5295 body: "",
5296 author: "user",
5297 url: &format!("https://github.com/{repo}/issues/{ext_id}"),
5298 labels: &[],
5299 origin: "github",
5300 })
5301 .await
5302 .unwrap();
5303 store
5304 .store_route(&StoreRoute {
5305 id,
5306 agent: "claude",
5307 model: Some("sonnet"),
5308 complexity: "medium",
5309 estimate: 0,
5310 reason: "test",
5311 profile: "{}",
5312 skills: "[]",
5313 })
5314 .await
5315 .unwrap();
5316 store.update_status(id, TaskStatus::Routed).await.unwrap();
5317 id
5318 }
5319
5320 let active_id = make_routed(&store, "active/repo", "1").await;
5321 let inactive_id_a = make_routed(&store, "inactive/repo", "2").await;
5322 let other_inactive_id = make_routed(&store, "other-inactive/repo", "3").await;
5323 let inactive_id_b = make_routed(&store, "inactive/repo", "4").await;
5324
5325 let all_routed = store
5326 .list_all_by_status_global(TaskStatus::Routed)
5327 .await
5328 .unwrap();
5329 let active_repos: std::collections::HashSet<String> =
5330 ["active/repo".to_string()].into_iter().collect();
5331 let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
5332
5333 assert!(
5334 !by_repo.contains_key("active/repo"),
5335 "global sweep must not select tasks for the active repo"
5336 );
5337 let inactive_ids: std::collections::HashSet<i64> = by_repo
5338 .get("inactive/repo")
5339 .expect("inactive/repo must be selected")
5340 .iter()
5341 .map(|t| t.id)
5342 .collect();
5343 assert_eq!(
5344 inactive_ids,
5345 [inactive_id_a, inactive_id_b].into_iter().collect(),
5346 "global sweep must group all orphaned tasks for a repo together"
5347 );
5348 assert_eq!(
5349 by_repo
5350 .get("other-inactive/repo")
5351 .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
5352 Some(vec![other_inactive_id])
5353 );
5354
5355 assert_eq!(
5358 store.get(active_id).await.unwrap().status,
5359 TaskStatus::Routed
5360 );
5361 }
5362
5363 #[tokio::test]
5368 async fn group_orphaned_routed_tasks_excludes_all_active_repos_not_just_current() {
5369 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
5370
5371 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5372
5373 async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
5374 let id = store
5375 .upsert_external(&UpsertExternal {
5376 repo,
5377 ext_id,
5378 title: "Routed task",
5379 body: "",
5380 author: "user",
5381 url: &format!("https://github.com/{repo}/issues/{ext_id}"),
5382 labels: &[],
5383 origin: "github",
5384 })
5385 .await
5386 .unwrap();
5387 store
5388 .store_route(&StoreRoute {
5389 id,
5390 agent: "claude",
5391 model: Some("sonnet"),
5392 complexity: "medium",
5393 estimate: 0,
5394 reason: "test",
5395 profile: "{}",
5396 skills: "[]",
5397 })
5398 .await
5399 .unwrap();
5400 store.update_status(id, TaskStatus::Routed).await.unwrap();
5401 id
5402 }
5403
5404 let repo_a_id = make_routed(&store, "active/repo-a", "1").await;
5405 let repo_b_id = make_routed(&store, "active/repo-b", "2").await;
5406 let orphaned_id = make_routed(&store, "inactive/repo", "3").await;
5407
5408 let all_routed = store
5409 .list_all_by_status_global(TaskStatus::Routed)
5410 .await
5411 .unwrap();
5412 let active_repos: std::collections::HashSet<String> =
5415 ["active/repo-a".to_string(), "active/repo-b".to_string()]
5416 .into_iter()
5417 .collect();
5418 let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
5419
5420 assert!(
5421 !by_repo.contains_key("active/repo-a"),
5422 "the current repo's own tasks must not be swept"
5423 );
5424 assert!(
5425 !by_repo.contains_key("active/repo-b"),
5426 "another active repo's tasks must not be swept during repo A's tick"
5427 );
5428 assert_eq!(
5429 by_repo
5430 .get("inactive/repo")
5431 .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
5432 Some(vec![orphaned_id]),
5433 "a genuinely inactive repo's tasks must still be swept"
5434 );
5435
5436 assert_eq!(
5438 store.get(repo_a_id).await.unwrap().status,
5439 TaskStatus::Routed
5440 );
5441 assert_eq!(
5442 store.get(repo_b_id).await.unwrap().status,
5443 TaskStatus::Routed
5444 );
5445 }
5446
5447 #[tokio::test]
5449 async fn global_dispatch_sweep_skips_no_agent_tasks() {
5450 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
5451
5452 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5453
5454 let id = store
5455 .upsert_external(&UpsertExternal {
5456 repo: "inactive/repo",
5457 ext_id: "99",
5458 title: "No-agent routed task",
5459 body: "",
5460 author: "user",
5461 url: "https://github.com/inactive/repo/issues/99",
5462 labels: &["no-agent".to_string()],
5463 origin: "github",
5464 })
5465 .await
5466 .unwrap();
5467 store
5468 .store_route(&StoreRoute {
5469 id,
5470 agent: "claude",
5471 model: Some("sonnet"),
5472 complexity: "medium",
5473 estimate: 0,
5474 reason: "test",
5475 profile: "{}",
5476 skills: "[]",
5477 })
5478 .await
5479 .unwrap();
5480 store.update_status(id, TaskStatus::Routed).await.unwrap();
5481
5482 let tmux = Arc::new(TmuxManager::new());
5487 let transport = Arc::new(Transport::new());
5488 let capture = Arc::new(CaptureService::new(transport));
5489 let semaphore = Arc::new(Semaphore::new(4));
5490 let (weight_tx, _weight_rx) = mpsc::channel(16);
5491 let dispatching = Arc::new(DashMap::new());
5492 let dispatch_mode = DispatchMode {
5493 is_degraded: false,
5494 healthy_agents: 1,
5495 threshold: 1,
5496 };
5497
5498 let active_repos: std::collections::HashSet<String> =
5499 ["active/repo".to_string()].into_iter().collect();
5500 dispatch_routed_tasks_global(
5501 &tmux,
5502 &capture,
5503 &semaphore,
5504 &weight_tx,
5505 dispatch_mode,
5506 &dispatching,
5507 &store,
5508 &std::collections::HashMap::new(),
5509 &active_repos,
5510 )
5511 .await
5512 .unwrap();
5513
5514 assert_eq!(
5515 store.get(id).await.unwrap().status,
5516 TaskStatus::Routed,
5517 "no-agent tasks must not be dispatched"
5518 );
5519 }
5520}