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 routing_quota.is_exhausted() {
1596 tracing::debug!("routing quota exhausted for this tick — skipping repo routing phase");
1597 return Ok(());
1598 }
1599
1600 if is_github_circuit_open() {
1604 let remaining = github_circuit_remaining_secs();
1605 tracing::info!(
1606 remaining_secs = remaining,
1607 "GitHub 5xx circuit breaker open — skipping routing phase"
1608 );
1609 return Ok(());
1610 }
1611 let new_tasks = task_manager.list_routable().await?;
1612 let routable: Vec<&ExternalTask> = new_tasks
1613 .iter()
1614 .filter(|t| !skips_agent(&t.labels))
1615 .collect();
1616
1617 if !routable.is_empty() {
1621 let start = std::time::Instant::now();
1622 router.refresh_health(store).await;
1623 tracing::debug!(
1624 duration_ms = start.elapsed().as_millis(),
1625 "router.refresh_health completed"
1626 );
1627 }
1628
1629 for task in routable.into_iter().take(routing_quota.remaining()) {
1631 if !routing_quota.consume_one() {
1632 break;
1633 }
1634 let _task_span = tracing::info_span!("engine.route", task_id = %task.id.0).entered();
1635
1636 let task_start = Instant::now();
1637 match router.route(task, store, repo).await {
1638 Ok(result) => {
1639 tracing::debug!(task_id = %task.id.0, duration_ms = task_start.elapsed().as_millis(), "route completed");
1640 if let Err(e) = router
1642 .store_route_result(&task.id.0, &result, store, repo)
1643 .await
1644 {
1645 tracing::warn!(
1646 task_id = task.id.0,
1647 ?e,
1648 "failed to store route result; skipping Routed transition"
1649 );
1650 continue;
1651 }
1652
1653 if is_internal_id(&task.id.0) {
1654 if let Err(e) = task_manager
1656 .update_task_status(&task.id, Status::Routed)
1657 .await
1658 {
1659 tracing::warn!(
1660 task_id = task.id.0,
1661 ?e,
1662 "failed to set internal status:routed"
1663 );
1664 continue;
1665 }
1666 } else {
1667 let backend_clone = Arc::clone(backend);
1671 let task_id_clone = task.id.clone();
1672 let task_labels: Vec<String> = task.labels.clone();
1673 let new_labels = {
1674 let mut labels = vec![
1675 format!("agent:{}", result.agent),
1676 format!("complexity:{}", result.complexity),
1677 ];
1678 if let Some(ref model) = result.model {
1679 labels.push(format!("model:{model}"));
1680 }
1681 labels
1682 };
1683 tokio::spawn(async move {
1684 for label in &task_labels {
1686 if label.starts_with("agent:")
1687 || label.starts_with("complexity:")
1688 || label.starts_with("model:")
1689 {
1690 if let Err(e) =
1691 backend_clone.remove_label(&task_id_clone, label).await
1692 {
1693 tracing::warn!(task_id = task_id_clone.0, label, error = %e, "failed to remove stale routing label during re-route");
1694 }
1695 }
1696 }
1697 if let Err(e) = backend_clone.set_labels(&task_id_clone, &new_labels).await
1699 {
1700 tracing::warn!(
1701 task_id = task_id_clone.0,
1702 ?e,
1703 "failed to set routing labels"
1704 );
1705 }
1706 });
1707
1708 if let Err(e) = task_manager
1710 .update_task_status(&task.id, Status::Routed)
1711 .await
1712 {
1713 tracing::warn!(task_id = task.id.0, ?e, "failed to set status:routed");
1714 continue;
1715 }
1716 }
1717
1718 match store.ensure_external_task(repo, task).await {
1720 Ok(store_id) => {
1721 let profile_json =
1722 serde_json::to_string(&result.profile).unwrap_or_default();
1723 let skills_json =
1724 serde_json::to_string(&result.selected_skills).unwrap_or_default();
1725 if let Err(e) = store
1726 .store_route(&crate::store::StoreRoute {
1727 id: store_id,
1728 agent: &result.agent,
1729 model: result.model.as_deref(),
1730 complexity: &result.complexity,
1731 estimate: result.estimate,
1732 reason: &result.reason,
1733 profile: &profile_json,
1734 skills: &skills_json,
1735 })
1736 .await
1737 {
1738 tracing::debug!(
1739 task_id = task.id.0,
1740 ?e,
1741 "dual-write: store_route failed"
1742 );
1743 }
1744 if let Err(e) = store
1745 .update_status(store_id, crate::store::TaskStatus::Routed)
1746 .await
1747 {
1748 tracing::debug!(
1749 task_id = task.id.0,
1750 ?e,
1751 "dual-write: update_status failed"
1752 );
1753 }
1754 if result.estimate > 0 {
1758 let backend_clone = Arc::clone(backend);
1759 let task_id = task.id.clone();
1760 let estimate = result.estimate;
1761 tokio::spawn(async move {
1762 if let Err(e) = backend_clone
1763 .sync_estimate_to_project(&task_id, estimate)
1764 .await
1765 {
1766 tracing::debug!(
1767 task_id = task_id.0,
1768 estimate,
1769 err = %e,
1770 "dual-write: estimate project sync failed"
1771 );
1772 }
1773 });
1774 }
1775 }
1776 Err(e) => {
1777 tracing::debug!(
1778 task_id = task.id.0,
1779 ?e,
1780 "dual-write: ensure_external_task failed"
1781 );
1782 }
1783 }
1784
1785 if let Some(ref warning) = result.warning {
1786 tracing::warn!(task_id = task.id.0, warning, "routing sanity warning");
1787 }
1788
1789 tracing::info!(
1790 task_id = task.id.0,
1791 agent = %result.agent,
1792 complexity = %result.complexity,
1793 reason = %result.reason,
1794 "task routed"
1795 );
1796 }
1797 Err(e) => {
1798 if let Some(cooled) = e.downcast_ref::<AllAgentsCooledError>() {
1799 tracing::error!(
1800 task_id = %task.id.0,
1801 scope = %cooled.scope(),
1802 remaining_secs = ?cooled.remaining_secs(),
1803 "ALL AGENTS COOLED — no agent available to route this task. \
1804 Task stays in 'new' and will be retried on every tick. \
1805 Check `orch cooldown list` to see what is blocking dispatch."
1806 );
1807 continue;
1808 }
1809 tracing::error!(task_id = task.id.0, ?e, "routing failed, skipping task");
1810 }
1811 }
1812 }
1813 Ok(())
1814}
1815
1816pub(crate) async fn route_new_tasks_global(
1832 router: &mut Router,
1833 store: &Arc<TaskStore>,
1834 active_repos: &std::collections::HashSet<String>,
1835 routing_quota: &mut RoutingTickQuota,
1836) -> anyhow::Result<()> {
1837 use crate::store::{StoreRoute, TaskStatus};
1838
1839 if routing_quota.is_exhausted() {
1840 tracing::debug!("routing quota exhausted for this tick — skipping global routing sweep");
1841 return Ok(());
1842 }
1843
1844 let all_new = store.list_all_by_status_global(TaskStatus::New).await?;
1845 let orphaned: Vec<&crate::store::Task> = all_new
1846 .iter()
1847 .filter(|t| !active_repos.contains(&t.repo))
1848 .filter(|t| !skips_agent(&t.labels))
1849 .collect();
1850
1851 if orphaned.is_empty() {
1852 return Ok(());
1853 }
1854
1855 tracing::info!(
1856 count = orphaned.len(),
1857 "global routing sweep: found new tasks from inactive repos"
1858 );
1859
1860 router.refresh_health(store).await;
1861
1862 for task in orphaned.into_iter().take(routing_quota.remaining()) {
1863 if !routing_quota.consume_one() {
1864 break;
1865 }
1866 let ext_task = crate::engine::tasks::store_task_to_external(task);
1867 let task_start = Instant::now();
1868 match router.route(&ext_task, store, &task.repo).await {
1869 Ok(result) => {
1870 tracing::debug!(
1871 task_id = %ext_task.id.0,
1872 repo = %task.repo,
1873 duration_ms = task_start.elapsed().as_millis(),
1874 "global sweep: route completed"
1875 );
1876 let profile_json = serde_json::to_string(&result.profile).unwrap_or_default();
1877 let skills_json =
1878 serde_json::to_string(&result.selected_skills).unwrap_or_default();
1879 if let Err(e) = store
1880 .store_route(&StoreRoute {
1881 id: task.id,
1882 agent: &result.agent,
1883 model: result.model.as_deref(),
1884 complexity: &result.complexity,
1885 estimate: result.estimate,
1886 reason: &result.reason,
1887 profile: &profile_json,
1888 skills: &skills_json,
1889 })
1890 .await
1891 {
1892 tracing::warn!(
1893 task_id = %ext_task.id.0,
1894 repo = %task.repo,
1895 err = %e,
1896 "global sweep: failed to store route result; skipping status update"
1897 );
1898 continue;
1899 }
1900 if let Err(e) = store.update_status(task.id, TaskStatus::Routed).await {
1901 tracing::warn!(
1902 task_id = %ext_task.id.0,
1903 repo = %task.repo,
1904 err = %e,
1905 "global sweep: failed to set status:routed"
1906 );
1907 } else {
1908 tracing::info!(
1909 task_id = %ext_task.id.0,
1910 repo = %task.repo,
1911 agent = %result.agent,
1912 complexity = %result.complexity,
1913 "global sweep: task routed"
1914 );
1915 }
1916 if let Some(ref warning) = result.warning {
1917 tracing::warn!(task_id = %ext_task.id.0, warning, "global sweep: routing sanity warning");
1918 }
1919 }
1920 Err(e) => {
1921 if let Some(cooled) = e.downcast_ref::<AllAgentsCooledError>() {
1922 tracing::error!(
1923 task_id = %ext_task.id.0,
1924 repo = %task.repo,
1925 scope = %cooled.scope(),
1926 remaining_secs = ?cooled.remaining_secs(),
1927 "global sweep: ALL AGENTS COOLED — task stays in 'new'"
1928 );
1929 continue;
1930 }
1931 tracing::error!(
1932 task_id = %ext_task.id.0,
1933 repo = %task.repo,
1934 err = ?e,
1935 "global sweep: routing failed, skipping task"
1936 );
1937 }
1938 }
1939 }
1940
1941 Ok(())
1942}
1943
1944#[derive(Debug, Clone, Copy)]
1946pub(crate) struct DispatchMode {
1947 pub(crate) is_degraded: bool,
1948 pub(crate) healthy_agents: usize,
1949 pub(crate) threshold: usize,
1950}
1951
1952pub(crate) fn dispatch_mode_from_router(router: &Router) -> DispatchMode {
1953 let threshold = crate::engine::router::config::min_healthy_agents_threshold();
1954 let healthy_agents = router.healthy_agent_count("simple");
1955 let is_degraded = router.is_degraded(threshold);
1956 DispatchMode {
1957 is_degraded,
1958 healthy_agents,
1959 threshold,
1960 }
1961}
1962
1963#[allow(clippy::too_many_arguments)]
1965#[tracing::instrument(skip_all, name = "engine.tick.phase3b.dispatch")]
1966pub(crate) async fn tick_dispatch_tasks(
1967 backend: &Arc<dyn ExternalBackend>,
1968 tmux: &Arc<TmuxManager>,
1969 repo: &str,
1970 runner: &Arc<TaskRunner>,
1971 capture: &Arc<CaptureService>,
1972 semaphore: &Arc<Semaphore>,
1973 task_manager: &Arc<TaskManager>,
1974 weight_tx: &mpsc::Sender<WeightSignal>,
1975 dispatch_mode: DispatchMode,
1976 dispatching: &Arc<DashMap<String, String>>,
1977 store: &Arc<TaskStore>,
1978 session_map: &std::collections::HashMap<String, bool>,
1979) -> anyhow::Result<()> {
1980 let mut routed_tasks = task_manager.list_external_by_status(Status::Routed).await?;
1982
1983 use crate::store::TaskStatus as DbStatus;
1985 let internal_routed = task_manager
1986 .list_internal_by_status(DbStatus::Routed)
1987 .await?;
1988 routed_tasks.extend(internal_routed);
1989
1990 let dispatchable: Vec<ExternalTask> = routed_tasks
1992 .into_iter()
1993 .filter(|t| !skips_agent(&t.labels))
1994 .collect();
1995
1996 dispatch_tasks_for_repo(
1997 backend,
1998 tmux,
1999 repo,
2000 runner,
2001 capture,
2002 semaphore,
2003 task_manager,
2004 weight_tx,
2005 dispatch_mode,
2006 dispatching,
2007 store,
2008 session_map,
2009 dispatchable,
2010 )
2011 .await
2012}
2013
2014#[allow(clippy::too_many_arguments)]
2020async fn dispatch_tasks_for_repo(
2021 backend: &Arc<dyn ExternalBackend>,
2022 tmux: &Arc<TmuxManager>,
2023 repo: &str,
2024 runner: &Arc<TaskRunner>,
2025 capture: &Arc<CaptureService>,
2026 semaphore: &Arc<Semaphore>,
2027 task_manager: &Arc<TaskManager>,
2028 weight_tx: &mpsc::Sender<WeightSignal>,
2029 dispatch_mode: DispatchMode,
2030 dispatching: &Arc<DashMap<String, String>>,
2031 store: &Arc<TaskStore>,
2032 session_map: &std::collections::HashMap<String, bool>,
2033 dispatchable: Vec<ExternalTask>,
2034) -> anyhow::Result<()> {
2035 if dispatchable.is_empty() {
2036 tracing::debug!(count = 0, "dispatchable tasks found");
2037 return Ok(());
2038 }
2039
2040 tracing::info!(count = dispatchable.len(), "dispatchable tasks found");
2041 if dispatch_mode.is_degraded {
2042 tracing::warn!(
2043 healthy_agents = dispatch_mode.healthy_agents,
2044 threshold = dispatch_mode.threshold,
2045 "degraded mode: using sequential dispatch"
2046 );
2047 }
2048
2049 let sequential_delay = if dispatch_mode.is_degraded {
2050 crate::engine::router::config::sequential_dispatch_delay_ms()
2051 } else {
2052 0
2053 };
2054
2055 for (idx, task) in dispatchable.into_iter().enumerate() {
2056 if idx > 0 && dispatch_mode.is_degraded {
2058 let delay_ms = sequential_delay;
2059 tracing::debug!(
2060 task_id = task.id.0,
2061 delay_ms = delay_ms,
2062 "sequential dispatch: waiting before dispatch"
2063 );
2064 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
2065 }
2066 let dispatch_key = format!("{}/{}", repo, task.id.0);
2073 {
2078 use dashmap::mapref::entry::Entry;
2079 match dispatching.entry(dispatch_key.clone()) {
2080 Entry::Occupied(existing) => {
2081 let existing_id = existing.get().clone();
2082 drop(existing); if existing_id == task.id.0 {
2084 tracing::debug!(
2085 task_id = task.id.0,
2086 "task already dispatching, skipping duplicate"
2087 );
2088 } else {
2089 tracing::warn!(
2090 task_id = task.id.0,
2091 existing_task_id = existing_id,
2092 dispatch_key,
2093 "dispatch key collision: unexpected task already holds this key"
2094 );
2095 }
2096 continue;
2097 }
2098 Entry::Vacant(slot) => {
2099 slot.insert(task.id.0.clone());
2100 tracing::info!(task_id = task.id.0, dispatch_key, "dispatch guard claimed");
2107 }
2108 }
2109 }
2110 let dispatch_guard = DispatchGuard::new(dispatching.clone(), dispatch_key.clone());
2112 let task_id = task.id.0.clone();
2113
2114 let route_result = match get_route_result(store, repo, &task_id).await {
2117 Ok(r) => Some(r),
2118 Err(RouteResultError::NoAgent { .. }) => {
2119 tracing::warn!(
2120 task_id,
2121 "routed task missing agent — resetting to new for re-routing (#1604)"
2122 );
2123 if let Err(e2) = task_manager.update_task_status(&task.id, Status::New).await {
2124 tracing::error!(task_id, error = %e2, "failed to reset task to new");
2125 }
2126 continue; }
2128 Err(e) => {
2129 tracing::warn!(task_id, error = %e, "get_route_result failed — dispatching without route info");
2130 None
2131 }
2132 };
2133
2134 if let Some(route) = route_result.as_ref() {
2140 let agent_cooled = is_agent_in_cooldown(&route.agent);
2141 let model_cooled = route
2142 .model
2143 .as_deref()
2144 .is_some_and(|model| is_model_in_cooldown(&route.agent, model));
2145 if agent_cooled || model_cooled {
2146 tracing::warn!(
2147 task_id,
2148 agent = route.agent,
2149 model = route.model.as_deref().unwrap_or(""),
2150 "routed agent/model entered cooldown after routing — deferring dispatch to next tick"
2151 );
2152 continue; }
2154 }
2155
2156 let session_name = tmux.session_name(repo, &task_id);
2158 if tmux
2159 .session_blocks_dispatch_from_map(&session_name, session_map)
2160 .await
2161 {
2162 tracing::warn!(
2163 task_id,
2164 session_name,
2165 "task has existing tmux session, skipping dispatch"
2166 );
2167 continue; }
2169
2170 let permit = match semaphore.clone().try_acquire_owned() {
2172 Ok(p) => p,
2173 Err(_) => {
2174 tracing::debug!("all parallel slots busy, skipping remaining tasks");
2175 break; }
2177 };
2178
2179 let set_in_progress_result = task_manager
2181 .update_task_status(&task.id, Status::InProgress)
2182 .await;
2183 if let Err(e) = set_in_progress_result {
2184 tracing::error!(task_id, ?e, "failed to set in_progress, skipping dispatch");
2185 drop(permit);
2186 continue; }
2188 tracing::info!(task_id, "dispatching task");
2189
2190 capture
2192 .register_session(repo, &task_id, &session_name)
2193 .await;
2194
2195 let runner = runner.clone();
2197 let backend = backend.clone();
2198 let tmux = tmux.clone();
2199 let capture = capture.clone();
2200 let task_id_for_cleanup = task_id.clone();
2201 let task_owned = task;
2202 let weight_tx = weight_tx.clone();
2203 let repo_owned = repo.to_string();
2204 let task_manager_for_spawn = task_manager.clone();
2205 let store_for_spawn = store.clone();
2206
2207 let repo_ctx = repo_owned.clone();
2208 tokio::spawn(REPO_CONTEXT.scope(repo_ctx, async move {
2209 let _dispatch_guard = dispatch_guard; let dispatch_start = std::time::Instant::now();
2211 match runner
2212 .run_with_context(&task_owned, &backend, &tmux, route_result.as_ref())
2213 .await
2214 {
2215 Ok(signal) => {
2216 tracing::info!(task_id, "task runner completed");
2217
2218 let duration = dispatch_start.elapsed().as_secs_f64();
2219
2220 let enable_review = config::get("workflow.enable_review_agent")
2226 .map(|v| v != "false")
2227 .unwrap_or(true);
2228 let display_status = match &signal {
2229 WeightSignal::Success { .. } if enable_review => {
2230 let has_pr = match store_for_spawn
2234 .resolve_task_id(&repo_owned, &task_id)
2235 .await
2236 {
2237 Ok(Some(store_id)) => match store_for_spawn
2238 .get(store_id)
2239 .await
2240 {
2241 Ok(t) => t.pr_number.is_some(),
2242 Err(e) => {
2243 tracing::warn!(task_id, err = %e, "store.get() failed — defaulting to needs_review");
2244 true
2245 }
2246 },
2247 Ok(None) => false,
2248 Err(e) => {
2249 tracing::warn!(task_id, err = %e, "failed to check PR status — defaulting to needs_review");
2250 true
2251 }
2252 };
2253 if has_pr {
2254 "needs_review"
2255 } else {
2256 "done"
2257 }
2258 }
2259 WeightSignal::Success { .. } => "done",
2260 WeightSignal::RateLimited { .. } => "new",
2261 WeightSignal::Blocked => "blocked",
2262 WeightSignal::Rerouted => "new",
2267 WeightSignal::None if enable_review => "needs_review",
2272 WeightSignal::None => "done",
2273 };
2274
2275 let _ = weight_tx.send(signal).await;
2277
2278 if display_status == "needs_review" {
2282 if let Err(e) = task_manager_for_spawn
2285 .update_task_status_with_duration(
2286 &ExternalId(task_id.clone()),
2287 Status::NeedsReview,
2288 Some(duration),
2289 )
2290 .await
2291 {
2292 tracing::error!(task_id, err = %e, "update_task_status(NeedsReview) failed — task may be stuck");
2293 }
2294 } else {
2296 let final_status = match display_status {
2298 "done" => Status::Done,
2299 "blocked" => Status::Blocked,
2300 _ => Status::New,
2301 };
2302 if let Err(e) = task_manager_for_spawn
2303 .update_task_status_with_duration(
2304 &ExternalId(task_id.clone()),
2305 final_status,
2306 Some(duration),
2307 )
2308 .await
2309 {
2310 tracing::warn!(task_id, ?e, "failed to update task status after completion");
2311 }
2312 }
2313 }
2314 Err(e) => {
2315 tracing::error!(task_id, ?e, "task runner failed");
2316 let duration = dispatch_start.elapsed().as_secs_f64();
2317 if is_internal_id(&task_id) {
2318 if let Err(ue) = task_manager_for_spawn
2320 .update_task_status_with_duration(
2321 &ExternalId(task_id.clone()),
2322 Status::NeedsReview,
2323 Some(duration),
2324 )
2325 .await
2326 {
2327 tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
2328 }
2329 } else {
2330 if let Err(comment_err) = backend
2332 .post_comment(
2333 &ExternalId(task_id.clone()),
2334 &format!(
2335 "[{}] error: task runner failed: {e}{}",
2336 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
2337 crate::engine::orch_footer()
2338 ),
2339 )
2340 .await
2341 {
2342 tracing::warn!(
2343 task_id,
2344 ?comment_err,
2345 "failed to post error comment to GitHub"
2346 );
2347 }
2348 if let Err(ue) = task_manager_for_spawn
2352 .update_task_status_with_duration(
2353 &ExternalId(task_id.clone()),
2354 Status::NeedsReview,
2355 Some(duration),
2356 )
2357 .await
2358 {
2359 tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
2360 }
2361 }
2362 }
2363 }
2364
2365 capture.unregister_session(&repo_owned, &task_id_for_cleanup).await;
2367
2368 drop(permit);
2370 }));
2372 }
2373 Ok(())
2374}
2375
2376fn skips_agent(labels: &[String]) -> bool {
2377 labels
2378 .iter()
2379 .any(|label| label == "no-agent" || label == "owner:human")
2380}
2381
2382fn group_orphaned_routed_tasks(
2389 all_routed: Vec<crate::store::Task>,
2390 active_repos: &std::collections::HashSet<String>,
2391) -> std::collections::HashMap<String, Vec<crate::store::Task>> {
2392 let mut by_repo: std::collections::HashMap<String, Vec<crate::store::Task>> =
2393 std::collections::HashMap::new();
2394 for task in all_routed
2395 .into_iter()
2396 .filter(|t| !active_repos.contains(&t.repo))
2397 .filter(|t| !skips_agent(&t.labels))
2398 {
2399 by_repo.entry(task.repo.clone()).or_default().push(task);
2400 }
2401 by_repo
2402}
2403
2404#[allow(clippy::too_many_arguments)]
2424pub(crate) async fn dispatch_routed_tasks_global(
2425 tmux: &Arc<TmuxManager>,
2426 capture: &Arc<CaptureService>,
2427 semaphore: &Arc<Semaphore>,
2428 weight_tx: &mpsc::Sender<WeightSignal>,
2429 dispatch_mode: DispatchMode,
2430 dispatching: &Arc<DashMap<String, String>>,
2431 store: &Arc<TaskStore>,
2432 session_map: &std::collections::HashMap<String, bool>,
2433 active_repos: &std::collections::HashSet<String>,
2434) -> anyhow::Result<()> {
2435 use crate::store::TaskStatus;
2436
2437 let all_routed = store.list_all_by_status_global(TaskStatus::Routed).await?;
2438 let by_repo = group_orphaned_routed_tasks(all_routed, active_repos);
2439
2440 if by_repo.is_empty() {
2441 return Ok(());
2442 }
2443
2444 tracing::info!(
2445 repos = by_repo.len(),
2446 "global dispatch sweep: found routed tasks from inactive repos"
2447 );
2448
2449 for (repo, tasks) in by_repo {
2450 let backend: Arc<dyn ExternalBackend> = match crate::backends::github::GitHubBackend::new(
2451 repo.clone(),
2452 ) {
2453 Ok(b) => Arc::new(b),
2454 Err(e) => {
2455 tracing::warn!(repo = %repo, err = %e, "global sweep: failed to construct backend, skipping repo");
2456 continue;
2457 }
2458 };
2459 let task_manager = Arc::new(TaskManager::with_store(
2460 backend.clone(),
2461 store.clone(),
2462 repo.clone(),
2463 ));
2464 let runner = Arc::new(TaskRunner::new(repo.clone()).with_store(store.clone()));
2465 let dispatchable: Vec<ExternalTask> = tasks
2466 .iter()
2467 .map(crate::engine::tasks::store_task_to_external)
2468 .collect();
2469
2470 if let Err(e) = dispatch_tasks_for_repo(
2471 &backend,
2472 tmux,
2473 &repo,
2474 &runner,
2475 capture,
2476 semaphore,
2477 &task_manager,
2478 weight_tx,
2479 dispatch_mode,
2480 dispatching,
2481 store,
2482 session_map,
2483 dispatchable,
2484 )
2485 .await
2486 {
2487 tracing::warn!(repo = %repo, err = %e, "global sweep: dispatch failed for repo");
2488 }
2489 }
2490
2491 Ok(())
2492}
2493
2494pub(crate) async fn tick_unblock_parents(
2496 backend: &Arc<dyn ExternalBackend>,
2497 task_manager: &Arc<TaskManager>,
2498 store: &Arc<TaskStore>,
2499 repo: &str,
2500) -> anyhow::Result<()> {
2501 let blocked = task_manager.list_all_by_status(Status::Blocked).await?;
2502
2503 let store_blocked = store
2507 .list_by_status(repo, crate::store::TaskStatus::Blocked)
2508 .await?;
2509 let has_block_reason: std::collections::HashSet<String> = store_blocked
2510 .iter()
2511 .filter(|t| t.block_reason.is_some())
2512 .filter_map(|t| t.external_id.clone())
2513 .collect();
2514
2515 let handles: Vec<_> = blocked
2519 .into_iter()
2520 .filter(|task| {
2521 if has_block_reason.contains(&task.id.0) {
2522 tracing::debug!(
2523 task_id = task.id.0,
2524 "skipping blocked task with known block_reason"
2525 );
2526 false
2527 } else {
2528 true
2529 }
2530 })
2531 .map(|task| {
2532 let backend_clone = Arc::clone(backend);
2533 let task_manager_clone = Arc::clone(task_manager);
2534 let store_clone = Arc::clone(store);
2535 let repo_owned = repo.to_string();
2536 tokio::spawn(async move {
2537 let task_id = &task.id;
2538
2539 let mut children: Vec<ExternalId> = Vec::new();
2540 let mut child_statuses: std::collections::HashMap<
2541 String,
2542 crate::store::TaskStatus,
2543 > = std::collections::HashMap::new();
2544
2545 match store_clone.resolve_task_id(&repo_owned, &task_id.0).await {
2546 Ok(Some(parent_store_id)) => {
2547 match store_clone.list_children(&repo_owned, parent_store_id).await {
2548 Ok(store_children) => {
2549 for child in store_children {
2550 let Some(child_external_id) = child.external_id else {
2551 continue;
2552 };
2553 child_statuses.insert(child_external_id.clone(), child.status);
2554 children.push(ExternalId(child_external_id));
2555 }
2556 }
2557 Err(e) => {
2558 tracing::debug!(
2559 task_id = task_id.0,
2560 ?e,
2561 "failed to list store-linked children"
2562 );
2563 }
2564 }
2565 }
2566 Ok(None) => {}
2567 Err(e) => {
2568 tracing::debug!(
2569 task_id = task_id.0,
2570 ?e,
2571 "failed to resolve blocked task in store for child lookup"
2572 );
2573 }
2574 }
2575
2576 if !is_internal_id(&task_id.0) {
2577 match backend_clone.get_sub_issues(task_id).await {
2578 Ok(ids) => {
2579 let mut seen: std::collections::HashSet<String> =
2580 children.iter().map(|child| child.0.clone()).collect();
2581 for id in ids {
2582 if seen.insert(id.0.clone()) {
2583 children.push(id);
2584 }
2585 }
2586 }
2587 Err(e) => {
2588 tracing::debug!(task_id = task_id.0, ?e, "failed to get sub-issues");
2589 }
2590 }
2591 }
2592
2593 if children.is_empty() {
2595 return;
2596 }
2597
2598 let mut all_done = true;
2601 let child_ext_strs: Vec<String> =
2603 children.iter().map(|c| c.0.clone()).collect();
2604 let child_exts: Vec<&str> =
2605 child_ext_strs.iter().map(|s| s.as_str()).collect();
2606 if let Ok(map) = store_clone
2608 .get_statuses_by_external_ids(&repo_owned, &child_exts)
2609 .await
2610 {
2611 for (k, v) in map.into_iter() {
2612 child_statuses.insert(k, v);
2613 }
2614 } else {
2615 tracing::debug!(
2616 task_id = task_id.0,
2617 "failed to batch-query store for child statuses; falling back to per-child lookups"
2618 );
2619 }
2620
2621 for child_id in &children {
2622 if let Some(status) = child_statuses.get(&child_id.0) {
2624 if *status != crate::store::TaskStatus::Done {
2625 all_done = false;
2626 break;
2627 }
2628 continue;
2629 }
2630
2631 match backend_clone.get_task(child_id).await {
2633 Ok(child) => {
2634 if !child.labels.iter().any(|l| l == Status::Done.as_label()) {
2635 all_done = false;
2636 break;
2637 }
2638 }
2639 Err(e) => {
2640 tracing::debug!(task_id = task_id.0, child = %child_id.0, ?e, "failed to fetch child task from backend — treating as not done");
2641 all_done = false;
2642 break;
2643 }
2644 }
2645 }
2646
2647 if all_done {
2648 tracing::info!(
2649 task_id = task_id.0,
2650 children = children.len(),
2651 "all children done, unblocking parent"
2652 );
2653 if let Err(e) =
2654 task_manager_clone.update_task_status(task_id, Status::New).await
2655 {
2656 tracing::warn!(task_id = task_id.0, ?e, "failed to unblock parent");
2657 }
2658 }
2659 })
2660 })
2661 .collect();
2662
2663 for handle in handles {
2664 if let Err(e) = handle.await {
2665 tracing::warn!(?e, "phase 4 sub-issue check panicked");
2666 }
2667 }
2668 Ok(())
2669}
2670
2671pub(crate) async fn tick_job_scheduler(
2673 jobs_path: &std::path::Path,
2674 backend: &Arc<dyn ExternalBackend>,
2675 store: Option<&Arc<TaskStore>>,
2676 repo: &str,
2677 transport: Option<&Arc<crate::channels::transport::Transport>>,
2678) -> anyhow::Result<()> {
2679 jobs::tick(jobs_path, backend, store, repo, transport).await
2680}
2681
2682#[allow(clippy::too_many_arguments)]
2692pub(crate) async fn tick(
2693 backend: &Arc<dyn ExternalBackend>,
2694 tmux: &Arc<TmuxManager>,
2695 repo: &str,
2696 runner: &Arc<TaskRunner>,
2697 capture: &Arc<CaptureService>,
2698 semaphore: &Arc<Semaphore>,
2699 config: &EngineConfig,
2700 jobs_path: &std::path::Path,
2701 router: &mut Router,
2702 task_manager: &Arc<TaskManager>,
2703 weight_tx: &mpsc::Sender<WeightSignal>,
2704 dispatching: &Arc<DashMap<String, String>>,
2705 store: &Arc<TaskStore>,
2706 transport: Option<&Arc<crate::channels::transport::Transport>>,
2707 active_repos: &std::collections::HashSet<String>,
2708 auto_merge_in_flight: &Arc<DashSet<String>>,
2709 routing_quota: &mut RoutingTickQuota,
2710) -> anyhow::Result<()> {
2711 let _tick_span = tracing::info_span!("engine.tick").entered();
2712
2713 startup_cleanup(tmux).await;
2715
2716 tick_check_session_completions(tmux, repo, capture, store).await?;
2717 tick_detect_silent_agents(tmux, repo, capture, backend, task_manager, config, store).await?;
2718 let session_map = tmux.batch_session_active().await;
2721 tick_recover_stuck_tasks(
2722 backend,
2723 tmux,
2724 repo,
2725 task_manager,
2726 config,
2727 store,
2728 &session_map,
2729 router,
2730 dispatching,
2731 auto_merge_in_flight,
2732 )
2733 .await?;
2734 tick_route_tasks(backend, task_manager, router, store, repo, routing_quota).await?;
2735 if let Err(e) = route_new_tasks_global(router, store, active_repos, routing_quota).await {
2739 tracing::warn!(err = %e, "global new-task routing sweep failed");
2740 }
2741 let dispatch_mode = dispatch_mode_from_router(router);
2742 tick_dispatch_tasks(
2743 backend,
2744 tmux,
2745 repo,
2746 runner,
2747 capture,
2748 semaphore,
2749 task_manager,
2750 weight_tx,
2751 dispatch_mode,
2752 dispatching,
2753 store,
2754 &session_map,
2755 )
2756 .await?;
2757 if let Err(e) = dispatch_routed_tasks_global(
2763 tmux,
2764 capture,
2765 semaphore,
2766 weight_tx,
2767 dispatch_mode,
2768 dispatching,
2769 store,
2770 &session_map,
2771 active_repos,
2772 )
2773 .await
2774 {
2775 tracing::warn!(err = %e, "global routed-task dispatch sweep failed");
2776 }
2777 tick_unblock_parents(backend, task_manager, store, repo).await?;
2778 if let Err(e) = tick_job_scheduler(jobs_path, backend, Some(store), repo, transport).await {
2779 tracing::error!(?e, "job scheduler tick failed");
2780 }
2781 Ok(())
2782}
2783
2784#[cfg(test)]
2785mod tests {
2786 use super::*;
2787 use crate::backends::{ExternalId, ExternalTask, Mention, Status};
2788 use crate::channels::transport::Transport;
2789 use crate::engine::tasks::TaskManager;
2790 use crate::store::TaskStore;
2791 use crate::tmux::{SessionGuard, TmuxManager};
2792 use async_trait::async_trait;
2793 use std::sync::{Arc, Mutex};
2794
2795 #[test]
2796 fn skips_agent_for_human_and_no_agent_labels() {
2797 assert!(skips_agent(&["owner:human".to_string()]));
2798 assert!(skips_agent(&["no-agent".to_string()]));
2799 assert!(!skips_agent(&["status:new".to_string()]));
2800 }
2801
2802 fn cooled_review_router_for_test() -> Router {
2806 Router::new_for_test(
2807 crate::engine::router::RouterConfig::default(),
2808 vec!["claude".to_string()],
2809 )
2810 }
2811
2812 struct MockBackend {
2816 blocked_tasks: Vec<ExternalTask>,
2818 sub_issues: std::collections::HashMap<String, Vec<ExternalId>>,
2820 tasks_by_id: std::collections::HashMap<String, ExternalTask>,
2822 get_sub_issues_calls: Arc<Mutex<Vec<String>>>,
2824 status_updates: Arc<Mutex<Vec<(String, Status)>>>,
2826 removed_labels: Arc<Mutex<Vec<(String, String)>>>,
2828 posted_comments: Arc<Mutex<Vec<(String, String)>>>,
2830 }
2831
2832 impl MockBackend {
2833 fn new() -> Self {
2834 Self {
2835 blocked_tasks: vec![],
2836 sub_issues: Default::default(),
2837 tasks_by_id: Default::default(),
2838 get_sub_issues_calls: Arc::new(Mutex::new(vec![])),
2839 status_updates: Arc::new(Mutex::new(vec![])),
2840 removed_labels: Arc::new(Mutex::new(vec![])),
2841 posted_comments: Arc::new(Mutex::new(vec![])),
2842 }
2843 }
2844 }
2845
2846 fn make_task(id: &str, labels: &[&str]) -> ExternalTask {
2847 ExternalTask {
2848 id: ExternalId(id.to_string()),
2849 title: format!("Task {id}"),
2850 body: "".to_string(),
2851 state: "open".to_string(),
2852 labels: labels.iter().map(|s| s.to_string()).collect(),
2853 author: "bot".to_string(),
2854 created_at: "2026-01-01T00:00:00Z".to_string(),
2855 updated_at: "2026-01-01T00:00:00Z".to_string(),
2856 url: format!("https://github.com/test/test/issues/{id}"),
2857 }
2858 }
2859
2860 #[async_trait]
2861 impl ExternalBackend for MockBackend {
2862 fn name(&self) -> &str {
2863 "mock"
2864 }
2865 async fn create_task(
2866 &self,
2867 _title: &str,
2868 _body: &str,
2869 _labels: &[String],
2870 ) -> anyhow::Result<ExternalId> {
2871 Ok(ExternalId("new".to_string()))
2872 }
2873 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
2874 self.tasks_by_id
2875 .get(&id.0)
2876 .cloned()
2877 .ok_or_else(|| anyhow::anyhow!("task not found: {}", id.0))
2878 }
2879 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
2880 if status == Status::Blocked {
2881 Ok(self.blocked_tasks.clone())
2882 } else {
2883 Ok(vec![])
2884 }
2885 }
2886 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2887 Ok(vec![])
2888 }
2889 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
2890 self.posted_comments
2891 .lock()
2892 .unwrap()
2893 .push((_id.0.clone(), _body.to_string()));
2894 Ok(())
2895 }
2896 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2897 Ok(())
2898 }
2899 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2900 self.removed_labels
2901 .lock()
2902 .unwrap()
2903 .push((_id.0.clone(), _label.to_string()));
2904 Ok(())
2905 }
2906 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2907 self.get_sub_issues_calls.lock().unwrap().push(id.0.clone());
2908 Ok(self.sub_issues.get(&id.0).cloned().unwrap_or_default())
2909 }
2910 async fn create_sub_task(
2911 &self,
2912 _parent: &ExternalId,
2913 _title: &str,
2914 _body: &str,
2915 _labels: &[String],
2916 ) -> anyhow::Result<ExternalId> {
2917 Ok(ExternalId("child".to_string()))
2918 }
2919 async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
2920 Ok(())
2921 }
2922 async fn has_open_issue_with_title(
2923 &self,
2924 _title: &str,
2925 _label: &str,
2926 ) -> anyhow::Result<bool> {
2927 Ok(false)
2928 }
2929 async fn health_check(&self) -> anyhow::Result<()> {
2930 Ok(())
2931 }
2932 async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
2933 Ok(false)
2934 }
2935 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
2936 Ok(Some("testbot".to_string()))
2937 }
2938 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
2939 Ok(vec![])
2940 }
2941 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
2942 self.status_updates
2943 .lock()
2944 .unwrap()
2945 .push((id.0.clone(), status));
2946 Ok(())
2947 }
2948 }
2949
2950 fn make_task_manager(backend: Arc<dyn ExternalBackend>) -> Arc<TaskManager> {
2951 Arc::new(TaskManager::new(backend))
2952 }
2953
2954 #[tokio::test]
2957 async fn unblock_parents_unblocks_when_all_children_done() {
2958 let mut mock = MockBackend::new();
2959
2960 let parent = make_task("10", &["status:blocked"]);
2962 mock.blocked_tasks.push(parent.clone());
2963 mock.sub_issues.insert(
2964 "10".to_string(),
2965 vec![ExternalId("11".to_string()), ExternalId("12".to_string())],
2966 );
2967 mock.tasks_by_id
2968 .insert("11".to_string(), make_task("11", &["status:done"]));
2969 mock.tasks_by_id
2970 .insert("12".to_string(), make_task("12", &["status:done"]));
2971
2972 let status_updates = mock.status_updates.clone();
2973 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
2974 let task_manager = make_task_manager(backend.clone());
2975 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2976
2977 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
2978 .await
2979 .unwrap();
2980
2981 let updates = status_updates.lock().unwrap();
2982 assert_eq!(updates.len(), 1, "parent should be unblocked");
2983 assert_eq!(updates[0], ("10".to_string(), Status::New));
2984 }
2985
2986 #[tokio::test]
2987 async fn unblock_parents_skips_when_child_not_done() {
2988 let mut mock = MockBackend::new();
2989
2990 let parent = make_task("20", &["status:blocked"]);
2991 mock.blocked_tasks.push(parent.clone());
2992 mock.sub_issues.insert(
2993 "20".to_string(),
2994 vec![ExternalId("21".to_string()), ExternalId("22".to_string())],
2995 );
2996 mock.tasks_by_id
2998 .insert("21".to_string(), make_task("21", &["status:done"]));
2999 mock.tasks_by_id
3000 .insert("22".to_string(), make_task("22", &["status:in_progress"]));
3001
3002 let status_updates = mock.status_updates.clone();
3003 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3004 let task_manager = make_task_manager(backend.clone());
3005 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3006
3007 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3008 .await
3009 .unwrap();
3010
3011 let updates = status_updates.lock().unwrap();
3012 assert!(
3013 updates.is_empty(),
3014 "should not unblock when a child is still running"
3015 );
3016 }
3017
3018 #[tokio::test]
3019 async fn unblock_parents_skips_task_with_no_children() {
3020 let mut mock = MockBackend::new();
3021
3022 let parent = make_task("30", &["status:blocked"]);
3024 mock.blocked_tasks.push(parent.clone());
3025 let status_updates = mock.status_updates.clone();
3028 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3029 let task_manager = make_task_manager(backend.clone());
3030 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3031
3032 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3033 .await
3034 .unwrap();
3035
3036 let updates = status_updates.lock().unwrap();
3037 assert!(
3038 updates.is_empty(),
3039 "should not unblock tasks with no sub-issues"
3040 );
3041 }
3042
3043 #[tokio::test]
3044 async fn unblock_parents_skips_task_with_block_reason_in_store() {
3045 let mut mock = MockBackend::new();
3048
3049 let parent = make_task("60", &["status:blocked"]);
3051 mock.blocked_tasks.push(parent.clone());
3052 mock.sub_issues
3055 .insert("60".to_string(), vec![ExternalId("61".to_string())]);
3056 mock.tasks_by_id
3057 .insert("61".to_string(), make_task("61", &["status:done"]));
3058
3059 let status_updates = mock.status_updates.clone();
3060 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3061 let task_manager = make_task_manager(backend.clone());
3062
3063 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3065 let task_id = store
3066 .upsert_external(&crate::store::UpsertExternal {
3067 repo: "test/repo",
3068 ext_id: "60",
3069 title: "Task 60",
3070 body: "",
3071 author: "bot",
3072 url: "https://github.com/test/test/issues/60",
3073 labels: &[],
3074 origin: "github",
3075 })
3076 .await
3077 .unwrap();
3078 store
3079 .update_status(task_id, crate::store::TaskStatus::Blocked)
3080 .await
3081 .unwrap();
3082 store
3083 .set_block_reason(task_id, Some("CI failure limit reached"))
3084 .await
3085 .unwrap();
3086
3087 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3088 .await
3089 .unwrap();
3090
3091 let updates = status_updates.lock().unwrap();
3092 assert!(
3093 updates.is_empty(),
3094 "task with block_reason should be skipped — no API call, no unblock"
3095 );
3096 }
3097
3098 #[tokio::test]
3099 async fn unblock_parents_unblocks_internal_parent_from_store_children() {
3100 let mock = MockBackend::new();
3101 let get_sub_issues_calls = mock.get_sub_issues_calls.clone();
3102 let status_updates = mock.status_updates.clone();
3103 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3104 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3105 let task_manager = Arc::new(TaskManager::with_store(
3106 backend.clone(),
3107 store.clone(),
3108 "test/repo".to_string(),
3109 ));
3110
3111 let parent_id = store
3112 .create_internal("test/repo", "Parent", "", "job", "daily", None)
3113 .await
3114 .unwrap();
3115 store
3116 .update_status(parent_id, crate::store::TaskStatus::Blocked)
3117 .await
3118 .unwrap();
3119 let child_id = store
3120 .create(&crate::store::NewTask {
3121 external_id: Some("91".to_string()),
3122 repo: "test/repo".to_string(),
3123 origin: "github".to_string(),
3124 title: "Child".to_string(),
3125 body: String::new(),
3126 source: String::new(),
3127 source_id: String::new(),
3128 author: String::new(),
3129 url: String::new(),
3130 labels: vec![],
3131 parent_id: Some(parent_id),
3132 })
3133 .await
3134 .unwrap();
3135 store
3136 .update_status(child_id, crate::store::TaskStatus::Done)
3137 .await
3138 .unwrap();
3139
3140 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3141 .await
3142 .unwrap();
3143
3144 let parent = store.get(parent_id).await.unwrap();
3145 assert_eq!(parent.status, crate::store::TaskStatus::New);
3146 assert!(
3147 status_updates.lock().unwrap().is_empty(),
3148 "internal parent should be updated in store without backend mirroring"
3149 );
3150 assert!(
3151 get_sub_issues_calls.lock().unwrap().is_empty(),
3152 "internal parent should not query backend sub-issues"
3153 );
3154 }
3155
3156 #[cfg(test)]
3160 async fn set_task_updated_at_past(store: &TaskStore, task_id: i64) {
3161 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
3162 .bind(task_id)
3163 .execute(store.pool())
3164 .await
3165 .unwrap();
3166 }
3167
3168 #[tokio::test]
3169 async fn recover_stuck_tasks_resets_external_in_review_to_needs_review() {
3170 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3171 let mock = MockBackend::new();
3172 let status_updates = mock.status_updates.clone();
3173 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3174 let task_manager = Arc::new(TaskManager::with_store(
3175 backend.clone(),
3176 store.clone(),
3177 "owner/repo".to_string(),
3178 ));
3179 let tmux = Arc::new(TmuxManager::new());
3180 let config = EngineConfig {
3181 no_session_stuck_timeout: 600,
3182 stuck_timeout: 1800,
3183 ..EngineConfig::default()
3184 };
3185
3186 let id = store
3188 .upsert_external(&crate::store::UpsertExternal {
3189 repo: "owner/repo",
3190 ext_id: "99",
3191 title: "Stuck InReview",
3192 body: "",
3193 author: "",
3194 url: "",
3195 labels: &[],
3196 origin: "github",
3197 })
3198 .await
3199 .unwrap();
3200 store
3201 .update_status(id, crate::store::TaskStatus::InReview)
3202 .await
3203 .unwrap();
3204 set_review_session_expected(&store, "owner/repo", "99", true).await;
3205 set_task_updated_at_past(&store, id).await;
3206
3207 tick_recover_stuck_tasks(
3208 &backend,
3209 &tmux,
3210 "owner/repo",
3211 &task_manager,
3212 &config,
3213 &store,
3214 &std::collections::HashMap::new(),
3215 &cooled_review_router_for_test(),
3216 &Arc::new(DashMap::new()),
3217 &Arc::new(DashSet::new()),
3218 )
3219 .await
3220 .unwrap();
3221
3222 let updates = status_updates.lock().unwrap();
3223 assert_eq!(updates.len(), 1, "stuck InReview task should be recovered");
3224 assert_eq!(updates[0].0, "99");
3225 assert_eq!(updates[0].1, Status::NeedsReview);
3226 }
3227
3228 #[tokio::test]
3229 async fn recover_stuck_tasks_skips_recent_in_review() {
3230 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3231 let mock = MockBackend::new();
3232 let status_updates = mock.status_updates.clone();
3233 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3234 let task_manager = Arc::new(TaskManager::with_store(
3235 backend.clone(),
3236 store.clone(),
3237 "owner/repo".to_string(),
3238 ));
3239 let tmux = Arc::new(TmuxManager::new());
3240 let config = EngineConfig {
3241 no_session_stuck_timeout: 600, stuck_timeout: 1800,
3243 ..EngineConfig::default()
3244 };
3245
3246 let id = store
3248 .upsert_external(&crate::store::UpsertExternal {
3249 repo: "owner/repo",
3250 ext_id: "100",
3251 title: "Recent InReview",
3252 body: "",
3253 author: "",
3254 url: "",
3255 labels: &[],
3256 origin: "github",
3257 })
3258 .await
3259 .unwrap();
3260 store
3261 .update_status(id, crate::store::TaskStatus::InReview)
3262 .await
3263 .unwrap();
3264 tick_recover_stuck_tasks(
3267 &backend,
3268 &tmux,
3269 "owner/repo",
3270 &task_manager,
3271 &config,
3272 &store,
3273 &std::collections::HashMap::new(),
3274 &cooled_review_router_for_test(),
3275 &Arc::new(DashMap::new()),
3276 &Arc::new(DashSet::new()),
3277 )
3278 .await
3279 .unwrap();
3280
3281 let updates = status_updates.lock().unwrap();
3282 assert!(
3283 updates.is_empty(),
3284 "recent InReview task should not be recovered"
3285 );
3286 }
3287
3288 #[tokio::test]
3289 async fn recover_stuck_tasks_resets_internal_in_review_to_needs_review() {
3290 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3291 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3292 let task_manager = Arc::new(TaskManager::with_store(
3293 backend.clone(),
3294 store.clone(),
3295 "owner/repo".to_string(),
3296 ));
3297 let tmux = Arc::new(TmuxManager::new());
3298 let config = EngineConfig {
3299 no_session_stuck_timeout: 600,
3300 stuck_timeout: 1800,
3301 ..EngineConfig::default()
3302 };
3303
3304 let id = store
3306 .create_internal("owner/repo", "Internal InReview", "", "cron", "1", None)
3307 .await
3308 .unwrap();
3309 store
3310 .update_status(id, crate::store::TaskStatus::InReview)
3311 .await
3312 .unwrap();
3313 set_review_session_expected(&store, "owner/repo", "internal:1", true).await;
3314 set_task_updated_at_past(&store, id).await;
3315
3316 tick_recover_stuck_tasks(
3317 &backend,
3318 &tmux,
3319 "owner/repo",
3320 &task_manager,
3321 &config,
3322 &store,
3323 &std::collections::HashMap::new(),
3324 &cooled_review_router_for_test(),
3325 &Arc::new(DashMap::new()),
3326 &Arc::new(DashSet::new()),
3327 )
3328 .await
3329 .unwrap();
3330
3331 let task = store.get(id).await.unwrap();
3332 assert_eq!(
3333 task.status,
3334 crate::store::TaskStatus::NeedsReview,
3335 "stuck internal InReview task should be reset to NeedsReview"
3336 );
3337 }
3338
3339 #[tokio::test]
3340 async fn recover_stuck_tasks_resets_cross_repo_external_in_progress_to_new() {
3341 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3342 let mock = MockBackend::new();
3343 let status_updates = mock.status_updates.clone();
3344 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3345 let task_manager = Arc::new(TaskManager::with_store(
3346 backend.clone(),
3347 store.clone(),
3348 "owner/repo".to_string(),
3349 ));
3350 let tmux = Arc::new(TmuxManager::new());
3351 let config = EngineConfig {
3352 no_session_stuck_timeout: 600,
3353 stuck_timeout: 1800,
3354 ..EngineConfig::default()
3355 };
3356
3357 let id = store
3359 .upsert_external(&crate::store::UpsertExternal {
3360 repo: "owner/other",
3361 ext_id: "777",
3362 title: "Cross-repo stuck task",
3363 body: "",
3364 author: "",
3365 url: "",
3366 labels: &[],
3367 origin: "github",
3368 })
3369 .await
3370 .unwrap();
3371 store
3372 .update_status(id, crate::store::TaskStatus::InProgress)
3373 .await
3374 .unwrap();
3375 set_task_updated_at_past(&store, id).await;
3376
3377 tick_recover_stuck_tasks(
3378 &backend,
3379 &tmux,
3380 "owner/repo",
3381 &task_manager,
3382 &config,
3383 &store,
3384 &std::collections::HashMap::new(),
3385 &cooled_review_router_for_test(),
3386 &Arc::new(DashMap::new()),
3387 &Arc::new(DashSet::new()),
3388 )
3389 .await
3390 .unwrap();
3391
3392 let task = store.get(id).await.unwrap();
3393 assert_eq!(
3394 task.status,
3395 crate::store::TaskStatus::New,
3396 "cross-repo stale in_progress task should be reset to New"
3397 );
3398 assert_eq!(task.route_attempts, 0, "route attempts should be cleared");
3399 assert!(task.agent.is_none(), "agent should be cleared");
3400 assert!(task.model.is_none(), "model should be cleared");
3401
3402 assert!(
3404 status_updates.lock().unwrap().is_empty(),
3405 "cross-repo recovery should not call backend update_status"
3406 );
3407 }
3408
3409 #[tokio::test]
3410 async fn recover_stuck_tasks_finalizes_open_task_runs_rows() {
3411 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3412 let mock = MockBackend::new();
3413 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3414 let task_manager = Arc::new(TaskManager::with_store(
3415 backend.clone(),
3416 store.clone(),
3417 "owner/repo".to_string(),
3418 ));
3419 let tmux = Arc::new(TmuxManager::new());
3420 let config = EngineConfig {
3421 no_session_stuck_timeout: 600,
3422 stuck_timeout: 1800,
3423 ..EngineConfig::default()
3424 };
3425
3426 let id = store
3427 .create_internal("owner/repo", "Stuck with open run", "", "cron", "1", None)
3428 .await
3429 .unwrap();
3430 store
3431 .set_fields(
3432 id,
3433 &[
3434 ("agent", serde_json::json!("claude")),
3435 ("model", serde_json::json!("sonnet")),
3436 ("route_attempts", serde_json::json!(1)),
3437 ],
3438 )
3439 .await
3440 .unwrap();
3441 store
3442 .update_status(id, crate::store::TaskStatus::InProgress)
3443 .await
3444 .unwrap();
3445 set_task_updated_at_past(&store, id).await;
3446
3447 store
3449 .start_run(&crate::store::StartRun {
3450 task_id: id,
3451 attempt: 1,
3452 run_type: "agent",
3453 agent: "claude",
3454 model: "sonnet",
3455 command: "claude --model sonnet",
3456 prompt: "fix it",
3457 })
3458 .await
3459 .unwrap();
3460
3461 tick_recover_stuck_tasks(
3462 &backend,
3463 &tmux,
3464 "owner/repo",
3465 &task_manager,
3466 &config,
3467 &store,
3468 &std::collections::HashMap::new(),
3469 &cooled_review_router_for_test(),
3470 &Arc::new(DashMap::new()),
3471 &Arc::new(DashSet::new()),
3472 )
3473 .await
3474 .unwrap();
3475
3476 let task = store.get(id).await.unwrap();
3477 assert_eq!(task.status, crate::store::TaskStatus::New);
3478
3479 let runs = store.get_runs(id).await.unwrap();
3480 assert_eq!(runs.len(), 1);
3481 assert_eq!(runs[0].outcome, "aborted");
3482 assert!(runs[0].completed_at.is_some());
3483 assert_eq!(
3484 runs[0].error,
3485 "stuck-task recovery: internal no session found"
3486 );
3487 }
3488
3489 #[tokio::test]
3506 async fn recover_stuck_tasks_skips_reclaim_while_dispatch_guard_held() {
3507 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3508 let mock = MockBackend::new();
3509 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3510 let task_manager = Arc::new(TaskManager::with_store(
3511 backend.clone(),
3512 store.clone(),
3513 "owner/repo".to_string(),
3514 ));
3515 let tmux = Arc::new(TmuxManager::new());
3516 let config = EngineConfig {
3517 no_session_stuck_timeout: 600,
3518 stuck_timeout: 1800,
3519 ..EngineConfig::default()
3520 };
3521
3522 let id = store
3523 .create_internal(
3524 "owner/repo",
3525 "Long-running daily job",
3526 "",
3527 "cron",
3528 "1",
3529 None,
3530 )
3531 .await
3532 .unwrap();
3533 store
3534 .set_fields(
3535 id,
3536 &[
3537 ("agent", serde_json::json!("claude")),
3538 ("model", serde_json::json!("sonnet")),
3539 ("route_attempts", serde_json::json!(1)),
3540 ],
3541 )
3542 .await
3543 .unwrap();
3544 store
3545 .update_status(id, crate::store::TaskStatus::InProgress)
3546 .await
3547 .unwrap();
3548 set_task_updated_at_past(&store, id).await;
3551
3552 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
3555 dispatching.insert(
3556 format!("owner/repo/internal:{id}"),
3557 format!("internal:{id}"),
3558 );
3559
3560 tick_recover_stuck_tasks(
3561 &backend,
3562 &tmux,
3563 "owner/repo",
3564 &task_manager,
3565 &config,
3566 &store,
3567 &std::collections::HashMap::new(),
3568 &cooled_review_router_for_test(),
3569 &dispatching,
3570 &Arc::new(DashSet::new()),
3571 )
3572 .await
3573 .unwrap();
3574
3575 let task = store.get(id).await.unwrap();
3576 assert_eq!(
3577 task.status,
3578 crate::store::TaskStatus::InProgress,
3579 "task should not be reclaimed while its dispatch guard is still held"
3580 );
3581
3582 let runs = store.get_runs(id).await.unwrap();
3584 assert!(runs.is_empty());
3585
3586 dispatching.clear();
3589 tick_recover_stuck_tasks(
3590 &backend,
3591 &tmux,
3592 "owner/repo",
3593 &task_manager,
3594 &config,
3595 &store,
3596 &std::collections::HashMap::new(),
3597 &cooled_review_router_for_test(),
3598 &dispatching,
3599 &Arc::new(DashSet::new()),
3600 )
3601 .await
3602 .unwrap();
3603 let task = store.get(id).await.unwrap();
3604 assert_eq!(
3605 task.status,
3606 crate::store::TaskStatus::New,
3607 "task should still be reclaimed once the dispatch guard is released"
3608 );
3609 }
3610
3611 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
3627 async fn recover_stuck_tasks_never_reclaims_while_guard_held_under_real_concurrency() {
3628 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3629 let mock = MockBackend::new();
3630 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3631 let task_manager = Arc::new(TaskManager::with_store(
3632 backend.clone(),
3633 store.clone(),
3634 "owner/repo".to_string(),
3635 ));
3636 let tmux = Arc::new(TmuxManager::new());
3637 let config = EngineConfig {
3638 no_session_stuck_timeout: 600,
3639 stuck_timeout: 1800,
3640 ..EngineConfig::default()
3641 };
3642
3643 let id = store
3644 .create_internal(
3645 "owner/repo",
3646 "Long-running daily job",
3647 "",
3648 "cron",
3649 "1",
3650 None,
3651 )
3652 .await
3653 .unwrap();
3654 store
3655 .set_fields(
3656 id,
3657 &[
3658 ("agent", serde_json::json!("claude")),
3659 ("model", serde_json::json!("sonnet")),
3660 ("route_attempts", serde_json::json!(1)),
3661 ],
3662 )
3663 .await
3664 .unwrap();
3665 store
3666 .update_status(id, crate::store::TaskStatus::InProgress)
3667 .await
3668 .unwrap();
3669 set_task_updated_at_past(&store, id).await;
3670
3671 let dispatch_key = format!("owner/repo/internal:{id}");
3672 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
3673
3674 let claim_task_id = format!("internal:{id}");
3681 {
3682 use dashmap::mapref::entry::Entry;
3683 match dispatching.entry(dispatch_key.clone()) {
3684 Entry::Vacant(slot) => {
3685 slot.insert(claim_task_id);
3686 }
3687 Entry::Occupied(_) => panic!("dispatch key already claimed"),
3688 }
3689 }
3690 let guard = DispatchGuard::new(Arc::clone(&dispatching), dispatch_key.clone());
3691
3692 let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
3703 let holder = tokio::spawn(async move {
3704 let _ = release_rx.await;
3705 drop(guard);
3706 });
3707
3708 const HAMMER_ITERATIONS: u32 = 300;
3716 for i in 0..HAMMER_ITERATIONS {
3717 tick_recover_stuck_tasks(
3718 &backend,
3719 &tmux,
3720 "owner/repo",
3721 &task_manager,
3722 &config,
3723 &store,
3724 &std::collections::HashMap::new(),
3725 &cooled_review_router_for_test(),
3726 &dispatching,
3727 &Arc::new(DashSet::new()),
3728 )
3729 .await
3730 .unwrap();
3731
3732 let task = store.get(id).await.unwrap();
3733 assert_eq!(
3734 task.status,
3735 crate::store::TaskStatus::InProgress,
3736 "task must never be reclaimed while the dispatch guard is deterministically \
3737 still held, even under real cross-thread concurrency (dispatch_key={dispatch_key}, \
3738 iteration={i}, contains_key_now={}, map_len_now={})",
3739 dispatching.contains_key(&dispatch_key),
3740 dispatching.len(),
3741 );
3742 tokio::task::yield_now().await;
3743 }
3744
3745 let _ = release_tx.send(());
3747 holder.await.unwrap();
3748
3749 tick_recover_stuck_tasks(
3751 &backend,
3752 &tmux,
3753 "owner/repo",
3754 &task_manager,
3755 &config,
3756 &store,
3757 &std::collections::HashMap::new(),
3758 &cooled_review_router_for_test(),
3759 &dispatching,
3760 &Arc::new(DashSet::new()),
3761 )
3762 .await
3763 .unwrap();
3764 let task = store.get(id).await.unwrap();
3765 assert_eq!(
3766 task.status,
3767 crate::store::TaskStatus::New,
3768 "task should be reclaimed once the dispatch guard is released"
3769 );
3770 }
3771
3772 #[tokio::test]
3773 async fn recover_stuck_tasks_skips_external_in_review_waiting_for_human_review() {
3774 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3775 let mock = MockBackend::new();
3776 let status_updates = mock.status_updates.clone();
3777 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3778 let task_manager = Arc::new(TaskManager::with_store(
3779 backend.clone(),
3780 store.clone(),
3781 "owner/repo".to_string(),
3782 ));
3783 let tmux = Arc::new(TmuxManager::new());
3784 let config = EngineConfig {
3785 no_session_stuck_timeout: 600,
3786 stuck_timeout: 1800,
3787 ..EngineConfig::default()
3788 };
3789
3790 let id = store
3791 .upsert_external(&crate::store::UpsertExternal {
3792 repo: "owner/repo",
3793 ext_id: "101",
3794 title: "Waiting For Human Review",
3795 body: "",
3796 author: "",
3797 url: "",
3798 labels: &[],
3799 origin: "github",
3800 })
3801 .await
3802 .unwrap();
3803 store
3804 .update_status(id, crate::store::TaskStatus::InReview)
3805 .await
3806 .unwrap();
3807 set_task_updated_at_past(&store, id).await;
3808
3809 tick_recover_stuck_tasks(
3810 &backend,
3811 &tmux,
3812 "owner/repo",
3813 &task_manager,
3814 &config,
3815 &store,
3816 &std::collections::HashMap::new(),
3817 &cooled_review_router_for_test(),
3818 &Arc::new(DashMap::new()),
3819 &Arc::new(DashSet::new()),
3820 )
3821 .await
3822 .unwrap();
3823
3824 let updates = status_updates.lock().unwrap();
3825 assert!(
3826 updates.is_empty(),
3827 "InReview task waiting for human review should not be reset"
3828 );
3829 }
3830
3831 #[tokio::test]
3832 async fn unblock_parents_no_blocked_tasks() {
3833 let mock = MockBackend::new(); let status_updates = mock.status_updates.clone();
3835 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3836 let task_manager = make_task_manager(backend.clone());
3837 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3838
3839 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3840 .await
3841 .unwrap();
3842
3843 assert!(status_updates.lock().unwrap().is_empty());
3844 }
3845
3846 #[tokio::test]
3847 async fn unblock_parents_handles_multiple_blocked_tasks() {
3848 let mut mock = MockBackend::new();
3849
3850 let t40 = make_task("40", &["status:blocked"]);
3852 mock.blocked_tasks.push(t40);
3853 mock.sub_issues
3854 .insert("40".to_string(), vec![ExternalId("41".to_string())]);
3855 mock.tasks_by_id
3856 .insert("41".to_string(), make_task("41", &["status:done"]));
3857
3858 let t50 = make_task("50", &["status:blocked"]);
3860 mock.blocked_tasks.push(t50);
3861 mock.sub_issues
3862 .insert("50".to_string(), vec![ExternalId("51".to_string())]);
3863 mock.tasks_by_id
3864 .insert("51".to_string(), make_task("51", &["status:in_progress"]));
3865
3866 let status_updates = mock.status_updates.clone();
3867 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3868 let task_manager = make_task_manager(backend.clone());
3869 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3870
3871 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3872 .await
3873 .unwrap();
3874
3875 let updates = status_updates.lock().unwrap();
3876 assert_eq!(updates.len(), 1, "only one parent should be unblocked");
3877 assert_eq!(updates[0].0, "40", "task 40 should be unblocked");
3878 assert_eq!(updates[0].1, Status::New);
3879 }
3880
3881 #[tokio::test]
3882 async fn detect_silent_agents_skips_backend_for_internal_tasks() {
3883 let transport = Arc::new(Transport::new());
3884 let capture = Arc::new(CaptureService::new(transport));
3885 let mock = Arc::new(MockBackend::new());
3886 let backend: Arc<dyn ExternalBackend> = mock.clone();
3887 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3888 let task_manager = Arc::new(TaskManager::with_store(
3889 backend.clone(),
3890 store.clone(),
3891 "owner/repo".to_string(),
3892 ));
3893 let tmux = Arc::new(TmuxManager::new());
3894 let config = EngineConfig {
3895 silence_grace_period: 0,
3896 ..EngineConfig::default()
3897 };
3898
3899 let internal_id = store
3900 .create_internal("owner/repo", "Silent internal", "", "cron", "1", None)
3901 .await
3902 .unwrap();
3903 store
3904 .update_status(internal_id, crate::store::TaskStatus::InProgress)
3905 .await
3906 .unwrap();
3907
3908 let task_id = format!("internal:{internal_id}");
3909 capture
3910 .register_session("owner/repo", &task_id, "orch-test-internal")
3911 .await;
3912 tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
3913
3914 tick_detect_silent_agents(
3915 &tmux,
3916 "owner/repo",
3917 &capture,
3918 &backend,
3919 &task_manager,
3920 &config,
3921 &store,
3922 )
3923 .await
3924 .unwrap();
3925
3926 assert!(
3927 mock.removed_labels.lock().unwrap().is_empty(),
3928 "internal tasks should skip backend label removal"
3929 );
3930 assert!(
3931 mock.posted_comments.lock().unwrap().is_empty(),
3932 "internal tasks should skip backend comments"
3933 );
3934
3935 let task = store.get(internal_id).await.unwrap();
3936 assert!(
3940 matches!(
3941 task.status,
3942 crate::store::TaskStatus::Routed | crate::store::TaskStatus::New
3943 ),
3944 "internal task should be routed to fallback or reset to new, got {:?}",
3945 task.status
3946 );
3947 }
3948
3949 #[tokio::test]
3950 async fn detect_silent_agents_finalizes_open_task_runs_rows() {
3951 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3952 let mock = MockBackend::new();
3953 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3954 let task_manager = Arc::new(TaskManager::with_store(
3955 backend.clone(),
3956 store.clone(),
3957 "owner/repo".to_string(),
3958 ));
3959 let transport = Arc::new(Transport::new());
3960 let capture = Arc::new(CaptureService::new(transport));
3961 let tmux = Arc::new(TmuxManager::new());
3962 let config = EngineConfig {
3963 silence_grace_period: 60,
3964 silence_cooldown: 300,
3965 ..EngineConfig::default()
3966 };
3967
3968 let internal_id = store
3969 .create_internal("owner/repo", "Silent with open run", "", "cron", "1", None)
3970 .await
3971 .unwrap();
3972 store
3973 .set_fields(
3974 internal_id,
3975 &[
3976 ("agent", serde_json::json!("claude")),
3977 ("model", serde_json::json!("sonnet")),
3978 ],
3979 )
3980 .await
3981 .unwrap();
3982 store
3983 .update_status(internal_id, crate::store::TaskStatus::InProgress)
3984 .await
3985 .unwrap();
3986
3987 let task_id = format!("internal:{internal_id}");
3988 capture
3989 .register_session("owner/repo", &task_id, "orch-test-silent-run")
3990 .await;
3991 capture
3992 .set_buffer_state_for_test(
3993 "owner/repo",
3994 &task_id,
3995 false,
3996 false,
3997 chrono::Utc::now() - chrono::Duration::seconds(120),
3998 )
3999 .await;
4000
4001 store
4003 .start_run(&crate::store::StartRun {
4004 task_id: internal_id,
4005 attempt: 1,
4006 run_type: "agent",
4007 agent: "claude",
4008 model: "sonnet",
4009 command: "claude --model sonnet",
4010 prompt: "fix it",
4011 })
4012 .await
4013 .unwrap();
4014
4015 tick_detect_silent_agents(
4016 &tmux,
4017 "owner/repo",
4018 &capture,
4019 &backend,
4020 &task_manager,
4021 &config,
4022 &store,
4023 )
4024 .await
4025 .unwrap();
4026
4027 let runs = store.get_runs(internal_id).await.unwrap();
4028 assert_eq!(runs.len(), 1);
4029 assert_eq!(runs[0].outcome, "aborted");
4030 assert!(runs[0].completed_at.is_some());
4031 assert_eq!(runs[0].error, "silence detection: session killed");
4032 }
4033
4034 #[tokio::test]
4045 async fn phase1_session_completion_touch_prevents_phase2_reclaim() {
4046 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4047 let mock = MockBackend::new();
4048 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4049 let task_manager = Arc::new(TaskManager::with_store(
4050 backend.clone(),
4051 store.clone(),
4052 "owner/repo".to_string(),
4053 ));
4054 let tmux = Arc::new(TmuxManager::new());
4055 let config = EngineConfig {
4056 no_session_stuck_timeout: 600,
4057 stuck_timeout: 1800,
4058 ..EngineConfig::default()
4059 };
4060
4061 let id = store
4065 .create_internal("owner/repo", "Completed task", "", "cron", "1", None)
4066 .await
4067 .unwrap();
4068 store
4069 .update_status(id, crate::store::TaskStatus::InProgress)
4070 .await
4071 .unwrap();
4072 set_task_updated_at_past(&store, id).await;
4073
4074 let task_id = format!("internal:{id}");
4077 store_touch_updated_at(&Some(Arc::clone(&store)), "owner/repo", &task_id).await;
4078
4079 tick_recover_stuck_tasks(
4081 &backend,
4082 &tmux,
4083 "owner/repo",
4084 &task_manager,
4085 &config,
4086 &store,
4087 &std::collections::HashMap::new(),
4088 &cooled_review_router_for_test(),
4089 &Arc::new(DashMap::new()),
4090 &Arc::new(DashSet::new()),
4091 )
4092 .await
4093 .unwrap();
4094
4095 let task = store.get(id).await.unwrap();
4096 assert_eq!(
4097 task.status,
4098 crate::store::TaskStatus::InProgress,
4099 "Phase 2 must not reclaim a task whose updated_at was just touched by Phase 1"
4100 );
4101 }
4102
4103 #[tokio::test]
4104 async fn stuck_task_timing_treats_dead_session_as_missing() {
4105 let tmux = Arc::new(TmuxManager::new());
4106 let repo = "owner/repo";
4107 let task_id = "internal:33498";
4108 let session_name = tmux.session_name(repo, task_id);
4109
4110 let create_result = tokio::process::Command::new("tmux")
4111 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
4112 .output()
4113 .await;
4114
4115 match create_result {
4116 Ok(o) if o.status.success() => {}
4117 _ => {
4118 eprintln!("Skipping test: tmux not available or failed to create test session");
4119 return;
4120 }
4121 }
4122
4123 let _guard = SessionGuard(session_name.clone());
4124
4125 let set_option_result = tokio::process::Command::new("tmux")
4126 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
4127 .output()
4128 .await;
4129 if !matches!(set_option_result, Ok(ref o) if o.status.success()) {
4130 eprintln!("Skipping test: unable to set tmux remain-on-exit option");
4131 return;
4132 }
4133
4134 let send_exit_result = tokio::process::Command::new("tmux")
4135 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
4136 .output()
4137 .await;
4138 if !matches!(send_exit_result, Ok(ref o) if o.status.success()) {
4139 eprintln!("Skipping test: unable to exit tmux pane");
4140 return;
4141 }
4142
4143 for _ in 0..20 {
4145 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4146 if !tmux.session_is_running(&session_name).await {
4147 break;
4148 }
4149 }
4150
4151 let mut session_map = tmux.batch_session_active().await;
4156 for _ in 0..20 {
4157 if !session_map.get(&session_name).copied().unwrap_or(false) {
4158 break;
4159 }
4160 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4161 session_map = tmux.batch_session_active().await;
4162 }
4163 if session_map.get(&session_name).copied().unwrap_or(false) {
4164 eprintln!("Skipping test: session still alive in batch_session_active after retries (CI tmux lag)");
4165 return;
4166 }
4167
4168 let config = EngineConfig {
4169 no_session_stuck_timeout: 600,
4170 stuck_timeout: 1800,
4171 ..EngineConfig::default()
4172 };
4173 let updated_at = (chrono::Utc::now()
4174 - chrono::Duration::seconds(config.no_session_stuck_timeout as i64 + 5))
4175 .to_rfc3339();
4176
4177 let timing = stuck_task_timing_from_map(
4178 &tmux,
4179 repo,
4180 task_id,
4181 &updated_at,
4182 &config,
4183 "parse failure",
4184 &session_map,
4185 )
4186 .expect("dead session should be treated as missing");
4187
4188 assert!(!timing.has_session);
4189 assert_eq!(timing.threshold, config.no_session_stuck_timeout);
4190 }
4191
4192 #[tokio::test]
4193 #[serial_test::serial(suspend_state)]
4194 async fn stuck_task_timing_discounts_host_suspend_gap() {
4195 super::super::suspend::clear_for_test();
4196
4197 let tmux = Arc::new(TmuxManager::new());
4198 let repo = "owner/repo";
4199 let task_id = "internal:99001";
4200 let session_map = std::collections::HashMap::new();
4201
4202 let config = EngineConfig {
4203 no_session_stuck_timeout: 1800,
4204 stuck_timeout: 1800,
4205 ..EngineConfig::default()
4206 };
4207
4208 let updated_at = (chrono::Utc::now() - chrono::Duration::minutes(33)).to_rfc3339();
4212 let suspend_gap_detected_at = chrono::Utc::now() - chrono::Duration::minutes(1);
4213 super::super::suspend::inject_gap_for_test(
4214 suspend_gap_detected_at,
4215 chrono::Duration::minutes(30),
4216 );
4217
4218 let timing = stuck_task_timing_from_map(
4219 &tmux,
4220 repo,
4221 task_id,
4222 &updated_at,
4223 &config,
4224 "parse failure",
4225 &session_map,
4226 );
4227
4228 assert!(
4229 timing.is_none(),
4230 "task age should be discounted below the stuck threshold once suspend time is subtracted, got {timing:?}"
4231 );
4232
4233 super::super::suspend::clear_for_test();
4234 }
4235
4236 #[tokio::test]
4237 async fn recover_stuck_tasks_reclaims_routed_task_blocked_by_stale_session() {
4238 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4239 let mock = MockBackend::new();
4240 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4241 let task_manager = Arc::new(TaskManager::with_store(
4242 backend.clone(),
4243 store.clone(),
4244 "owner/repo".to_string(),
4245 ));
4246 let tmux = Arc::new(TmuxManager::new());
4247 let config = EngineConfig {
4248 no_session_stuck_timeout: 600,
4249 stuck_timeout: 1,
4250 ..EngineConfig::default()
4251 };
4252
4253 let id = store
4254 .create_internal("owner/repo", "Blocked routed task", "", "cron", "1", None)
4255 .await
4256 .unwrap();
4257 store
4258 .update_status(id, crate::store::TaskStatus::Routed)
4259 .await
4260 .unwrap();
4261 set_task_updated_at_past(&store, id).await;
4262
4263 let task_id = format!("internal:{id}");
4264 let session_name = tmux.session_name("owner/repo", &task_id);
4265 let create_result = tokio::process::Command::new("tmux")
4266 .args([
4267 "new-session",
4268 "-d",
4269 "-s",
4270 &session_name,
4271 "-c",
4272 "/tmp",
4273 "sleep 60",
4274 ])
4275 .output()
4276 .await;
4277
4278 match create_result {
4279 Ok(o) if o.status.success() => {}
4280 _ => {
4281 eprintln!("Skipping test: tmux not available or failed to create test session");
4282 return;
4283 }
4284 }
4285
4286 let session_map = tmux.batch_session_active().await;
4287 assert_eq!(session_map.get(&session_name).copied(), Some(true));
4288
4289 tick_recover_stuck_tasks(
4290 &backend,
4291 &tmux,
4292 "owner/repo",
4293 &task_manager,
4294 &config,
4295 &store,
4296 &session_map,
4297 &cooled_review_router_for_test(),
4298 &Arc::new(DashMap::new()),
4299 &Arc::new(DashSet::new()),
4300 )
4301 .await
4302 .unwrap();
4303
4304 let task = store.get(id).await.unwrap();
4305 assert_eq!(
4306 task.status,
4307 crate::store::TaskStatus::New,
4308 "routed task blocked by a stale session should be reset for re-routing"
4309 );
4310 assert!(
4311 !tmux.session_exists(&session_name).await,
4312 "recovery should kill the stale tmux session so dispatch can proceed"
4313 );
4314 }
4315
4316 #[tokio::test]
4330 async fn dispatch_does_not_deadlock_under_write_lock() {
4331 use crate::engine::router::Router;
4332 use tokio::sync::RwLock;
4333
4334 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4335 let mock = MockBackend::new();
4336 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4337 let task_manager = Arc::new(TaskManager::with_store(
4338 backend.clone(),
4339 store.clone(),
4340 "owner/repo".to_string(),
4341 ));
4342 let tmux = Arc::new(TmuxManager::new());
4343 let semaphore = Arc::new(Semaphore::new(4));
4344 let (weight_tx, _weight_rx) = mpsc::channel(16);
4345 let dispatching = Arc::new(DashMap::new());
4346 let transport = Arc::new(Transport::new());
4347 let capture = Arc::new(CaptureService::new(transport));
4348
4349 let router_config = crate::engine::router::RouterConfig::default();
4350 let router = Router::new(router_config);
4351 let router_arc = Arc::new(RwLock::new(router));
4352
4353 let write_guard = router_arc.write().await;
4357 let dispatch_mode = dispatch_mode_from_router(&write_guard);
4358
4359 let result = tokio::time::timeout(
4360 std::time::Duration::from_secs(2),
4361 tick_dispatch_tasks(
4362 &backend,
4363 &tmux,
4364 "owner/repo",
4365 &Arc::new(crate::engine::runner::TaskRunner::new(
4366 "owner/repo".to_string(),
4367 )),
4368 &capture,
4369 &semaphore,
4370 &task_manager,
4371 &weight_tx,
4372 dispatch_mode,
4373 &dispatching,
4374 &store,
4375 &std::collections::HashMap::new(),
4376 ),
4377 )
4378 .await;
4379
4380 assert!(
4381 result.is_ok(),
4382 "tick_dispatch_tasks deadlocked! It tried to acquire a read lock \
4383 on router_arc while a write lock was already held (issue #1361)"
4384 );
4385 }
4386
4387 #[tokio::test]
4395 async fn dispatch_skips_task_whose_routed_model_entered_cooldown() {
4396 use crate::store::{StoreRoute, TaskStatus as DbStatus, UpsertExternal};
4397
4398 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4399 let mock = MockBackend::new();
4400 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4401 let task_manager = Arc::new(TaskManager::with_store(
4402 backend.clone(),
4403 store.clone(),
4404 "owner/repo".to_string(),
4405 ));
4406 let tmux = Arc::new(TmuxManager::new());
4407 let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
4408 let semaphore = Arc::new(Semaphore::new(4));
4409 let (weight_tx, _weight_rx) = mpsc::channel(16);
4410 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4411 let transport = Arc::new(Transport::new());
4412 let capture = Arc::new(CaptureService::new(transport));
4413
4414 let id = store
4415 .upsert_external(&UpsertExternal {
4416 repo: "owner/repo",
4417 ext_id: "999",
4418 title: "Stale routed task",
4419 body: "",
4420 author: "user",
4421 url: "https://github.com/owner/repo/issues/999",
4422 labels: &[],
4423 origin: "github",
4424 })
4425 .await
4426 .unwrap();
4427 store
4428 .store_route(&StoreRoute {
4429 id,
4430 agent: "minimax",
4431 model: Some("opus"),
4432 complexity: "medium",
4433 estimate: 0,
4434 reason: "test",
4435 profile: "{}",
4436 skills: "[]",
4437 })
4438 .await
4439 .unwrap();
4440 store.update_status(id, DbStatus::Routed).await.unwrap();
4441
4442 crate::engine::cooldown::set_model_cooldown(
4445 "minimax",
4446 "opus",
4447 300,
4448 "rate_limit_usage_window",
4449 )
4450 .await;
4451
4452 let dispatch_mode = DispatchMode {
4453 is_degraded: false,
4454 healthy_agents: 1,
4455 threshold: 1,
4456 };
4457
4458 tick_dispatch_tasks(
4459 &backend,
4460 &tmux,
4461 "owner/repo",
4462 &runner,
4463 &capture,
4464 &semaphore,
4465 &task_manager,
4466 &weight_tx,
4467 dispatch_mode,
4468 &dispatching,
4469 &store,
4470 &std::collections::HashMap::new(),
4471 )
4472 .await
4473 .unwrap();
4474
4475 let task = store.get(id).await.unwrap();
4476 assert_eq!(
4477 task.status,
4478 DbStatus::Routed,
4479 "task whose routed model is now cooled down must stay Routed, not be dispatched"
4480 );
4481 assert!(
4482 dispatching.is_empty(),
4483 "dispatch guard must be released when skipping a stale-cooldown task"
4484 );
4485 }
4486
4487 #[tokio::test]
4500 async fn completed_session_does_not_cause_stuck_reclaim_race() {
4501 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4502 let mock = MockBackend::new();
4503 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4504 let task_manager = Arc::new(TaskManager::with_store(
4505 backend.clone(),
4506 store.clone(),
4507 "owner/repo".to_string(),
4508 ));
4509 let tmux = Arc::new(TmuxManager::new());
4510 let transport = Arc::new(Transport::new());
4511 let capture = Arc::new(CaptureService::new(transport));
4512
4513 let id = store
4515 .upsert_external(&crate::store::UpsertExternal {
4516 repo: "owner/repo",
4517 ext_id: "200",
4518 title: "Race Task",
4519 body: "",
4520 author: "",
4521 url: "",
4522 labels: &[],
4523 origin: "github",
4524 })
4525 .await
4526 .unwrap();
4527 store
4528 .update_status(id, crate::store::TaskStatus::InProgress)
4529 .await
4530 .unwrap();
4531
4532 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4534 .bind(id)
4535 .execute(store.pool())
4536 .await
4537 .unwrap();
4538
4539 let task_id = "200";
4541 let session_name = tmux.session_name("owner/repo", task_id);
4542 let create_result = tokio::process::Command::new("tmux")
4543 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
4544 .output()
4545 .await;
4546 match create_result {
4547 Ok(o) if o.status.success() => {}
4548 _ => {
4549 eprintln!("Skipping test: tmux not available or failed to create test session");
4550 return;
4551 }
4552 }
4553 let _guard = SessionGuard(session_name.clone());
4554
4555 let _ = tokio::process::Command::new("tmux")
4557 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
4558 .output()
4559 .await;
4560 let _ = tokio::process::Command::new("tmux")
4561 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
4562 .output()
4563 .await;
4564
4565 for _ in 0..20 {
4567 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4568 if !tmux.session_is_running(&session_name).await {
4569 break;
4570 }
4571 }
4572
4573 let task_key = task_id.to_string();
4575 capture
4576 .register_session("owner/repo", &task_key, &session_name)
4577 .await;
4578
4579 let config = EngineConfig {
4581 no_session_stuck_timeout: 600,
4582 stuck_timeout: 1800,
4583 ..EngineConfig::default()
4584 };
4585
4586 tick_check_session_completions(&tmux, "owner/repo", &capture, &store)
4587 .await
4588 .unwrap();
4589
4590 {
4596 let task_state = store.get(id).await.unwrap();
4597 if task_state.updated_at.starts_with("2020") {
4598 eprintln!(
4599 "Skipping test: tick_check_session_completions did not update stored \
4600 updated_at — session was not in snapshot (CI tmux environment issue)"
4601 );
4602 return;
4603 }
4604 }
4605
4606 tick_recover_stuck_tasks(
4608 &backend,
4609 &tmux,
4610 "owner/repo",
4611 &task_manager,
4612 &config,
4613 &store,
4614 &std::collections::HashMap::new(),
4615 &cooled_review_router_for_test(),
4616 &Arc::new(DashMap::new()),
4617 &Arc::new(DashSet::new()),
4618 )
4619 .await
4620 .unwrap();
4621
4622 let task = store.get(id).await.unwrap();
4624 assert_eq!(
4625 task.status,
4626 crate::store::TaskStatus::InProgress,
4627 "task should not be reclaimed to New"
4628 );
4629 }
4630
4631 #[tokio::test]
4641 async fn session_completion_matches_repo_configured_with_git_suffix() {
4642 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4643 let tmux = Arc::new(TmuxManager::new());
4644 let transport = Arc::new(Transport::new());
4645 let capture = Arc::new(CaptureService::new(transport));
4646
4647 let repo = "owner/repo.git";
4648 let id = store
4649 .upsert_external(&crate::store::UpsertExternal {
4650 repo,
4651 ext_id: "201",
4652 title: "Git-suffix repo task",
4653 body: "",
4654 author: "",
4655 url: "",
4656 labels: &[],
4657 origin: "github",
4658 })
4659 .await
4660 .unwrap();
4661 store
4662 .update_status(id, crate::store::TaskStatus::InProgress)
4663 .await
4664 .unwrap();
4665 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4666 .bind(id)
4667 .execute(store.pool())
4668 .await
4669 .unwrap();
4670
4671 let task_id = "201";
4674 let session_name = tmux.session_name(repo, task_id);
4675 let create_result = tokio::process::Command::new("tmux")
4676 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
4677 .output()
4678 .await;
4679 match create_result {
4680 Ok(o) if o.status.success() => {}
4681 _ => {
4682 eprintln!("Skipping test: tmux not available or failed to create test session");
4683 return;
4684 }
4685 }
4686 let _guard = SessionGuard(session_name.clone());
4687
4688 let _ = tokio::process::Command::new("tmux")
4689 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
4690 .output()
4691 .await;
4692 let _ = tokio::process::Command::new("tmux")
4693 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
4694 .output()
4695 .await;
4696
4697 for _ in 0..20 {
4698 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4699 if !tmux.session_is_running(&session_name).await {
4700 break;
4701 }
4702 }
4703
4704 capture.register_session(repo, task_id, &session_name).await;
4705
4706 tick_check_session_completions(&tmux, repo, &capture, &store)
4707 .await
4708 .unwrap();
4709
4710 let task_state = store.get(id).await.unwrap();
4711 if task_state.updated_at.starts_with("2020") {
4712 eprintln!(
4713 "Skipping test: tick_check_session_completions did not update stored \
4714 updated_at — session was not in snapshot (CI tmux environment issue)"
4715 );
4716 return;
4717 }
4718 assert!(
4719 !task_state.updated_at.starts_with("2020"),
4720 "tick_check_session_completions must match its own repo's live sessions \
4721 even when the repo is configured with a \".git\" suffix"
4722 );
4723 }
4724
4725 #[tokio::test]
4731 async fn job_scheduler_returns_ok_on_duplicate_job_config() {
4732 let tmp = tempfile::tempdir().unwrap();
4733 let cfg = tmp.path().join(".orch.yml");
4734 std::fs::write(
4735 &cfg,
4736 "jobs:\n - id: trading-scan\n schedule: \"0 9 * * *\"\n task:\n title: Scan\n body: do it\n",
4737 )
4738 .unwrap();
4739 let jobs_dir = tmp.path().join("prompts").join("jobs");
4740 std::fs::create_dir_all(&jobs_dir).unwrap();
4741 std::fs::write(
4742 jobs_dir.join("trading-scan.md"),
4743 "---\nid: trading-scan\nschedule: '0 9 * * *'\ntitle: Trading Scan\n---\n\nDo trading scan.\n",
4744 )
4745 .unwrap();
4746
4747 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4748 let result = tick_job_scheduler(&cfg, &backend, None, "test/repo", None).await;
4749 assert!(
4750 result.is_ok(),
4751 "duplicate job id must not propagate as Err: {result:?}"
4752 );
4753 }
4754
4755 #[tokio::test]
4759 async fn job_scheduler_processes_valid_project_when_other_has_duplicate() {
4760 let bad_tmp = tempfile::tempdir().unwrap();
4762 let bad_cfg = bad_tmp.path().join(".orch.yml");
4763 std::fs::write(
4764 &bad_cfg,
4765 "jobs:\n - id: dup\n schedule: \"0 9 * * *\"\n task:\n title: Dup\n body: x\n",
4766 )
4767 .unwrap();
4768 let bad_jobs_dir = bad_tmp.path().join("prompts").join("jobs");
4769 std::fs::create_dir_all(&bad_jobs_dir).unwrap();
4770 std::fs::write(
4771 bad_jobs_dir.join("dup.md"),
4772 "---\nid: dup\nschedule: '0 9 * * *'\ntitle: Dup\n---\n\nbody\n",
4773 )
4774 .unwrap();
4775
4776 let good_tmp = tempfile::tempdir().unwrap();
4778 let good_cfg = good_tmp.path().join(".orch.yml");
4779 std::fs::write(
4780 &good_cfg,
4781 "jobs:\n - id: valid-job\n schedule: \"0 9 * * *\"\n task:\n title: Valid\n body: ok\n",
4782 )
4783 .unwrap();
4784
4785 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4786
4787 let bad_result = tick_job_scheduler(&bad_cfg, &backend, None, "test/bad-repo", None).await;
4788 assert!(
4789 bad_result.is_ok(),
4790 "bad project must not abort scheduler: {bad_result:?}"
4791 );
4792
4793 let good_result =
4794 tick_job_scheduler(&good_cfg, &backend, None, "test/good-repo", None).await;
4795 assert!(
4796 good_result.is_ok(),
4797 "valid project must still run after bad project: {good_result:?}"
4798 );
4799 }
4800
4801 #[serial_test::serial(cooldown_state)]
4809 #[tokio::test]
4810 async fn global_sweep_routes_inactive_repo_tasks() {
4811 use crate::engine::router::{Router, RouterConfig};
4812 use crate::store::{TaskStatus, UpsertExternal};
4813
4814 crate::engine::cooldown::reset_global_state().await;
4815
4816 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4817
4818 let active_id = store
4820 .upsert_external(&UpsertExternal {
4821 repo: "active/repo",
4822 ext_id: "1",
4823 title: "Active task",
4824 body: "",
4825 author: "user",
4826 url: "https://github.com/active/repo/issues/1",
4827 labels: &[],
4828 origin: "github",
4829 })
4830 .await
4831 .unwrap();
4832
4833 let inactive_id = store
4835 .upsert_external(&UpsertExternal {
4836 repo: "inactive/repo",
4837 ext_id: "42",
4838 title: "Orphaned task",
4839 body: "",
4840 author: "user",
4841 url: "https://github.com/inactive/repo/issues/42",
4842 labels: &[],
4843 origin: "github",
4844 })
4845 .await
4846 .unwrap();
4847
4848 assert_eq!(store.get(active_id).await.unwrap().status, TaskStatus::New);
4849 assert_eq!(
4850 store.get(inactive_id).await.unwrap().status,
4851 TaskStatus::New
4852 );
4853
4854 let mut model_map: std::collections::HashMap<
4855 String,
4856 std::collections::HashMap<String, Vec<String>>,
4857 > = std::collections::HashMap::new();
4858 let mut tier = std::collections::HashMap::new();
4859 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
4860 model_map.insert("medium".to_string(), tier);
4861 let config = RouterConfig {
4862 mode: "round_robin".to_string(),
4863 agents: vec!["claude".to_string()],
4864 model_map,
4865 ..Default::default()
4866 };
4867 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
4868
4869 let active_repos: std::collections::HashSet<String> =
4870 ["active/repo".to_string()].into_iter().collect();
4871 let mut routing_quota = RoutingTickQuota::new(1);
4872 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
4873 .await
4874 .unwrap();
4875
4876 assert_eq!(
4878 store.get(active_id).await.unwrap().status,
4879 TaskStatus::New,
4880 "global sweep must not touch tasks for the active repo"
4881 );
4882
4883 let after = store.get(inactive_id).await.unwrap();
4885 assert_eq!(
4886 after.status,
4887 TaskStatus::Routed,
4888 "global sweep must route tasks from inactive repos"
4889 );
4890 assert!(
4891 matches!(&after.agent, Some(a) if !a.is_empty()),
4892 "agent must be set after routing"
4893 );
4894 }
4895
4896 #[serial_test::serial(cooldown_state)]
4904 #[tokio::test]
4905 async fn global_sweep_does_not_touch_other_active_repo_tasks() {
4906 use crate::engine::router::{Router, RouterConfig};
4907 use crate::store::{TaskStatus, UpsertExternal};
4908
4909 crate::engine::cooldown::reset_global_state().await;
4910
4911 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4912
4913 let other_active_id = store
4916 .upsert_external(&UpsertExternal {
4917 repo: "other-active/repo",
4918 ext_id: "7",
4919 title: "Other active project's task",
4920 body: "",
4921 author: "user",
4922 url: "https://github.com/other-active/repo/issues/7",
4923 labels: &[],
4924 origin: "github",
4925 })
4926 .await
4927 .unwrap();
4928
4929 let inactive_id = store
4931 .upsert_external(&UpsertExternal {
4932 repo: "inactive/repo",
4933 ext_id: "42",
4934 title: "Orphaned task",
4935 body: "",
4936 author: "user",
4937 url: "https://github.com/inactive/repo/issues/42",
4938 labels: &[],
4939 origin: "github",
4940 })
4941 .await
4942 .unwrap();
4943
4944 let mut model_map: std::collections::HashMap<
4945 String,
4946 std::collections::HashMap<String, Vec<String>>,
4947 > = std::collections::HashMap::new();
4948 let mut tier = std::collections::HashMap::new();
4949 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
4950 model_map.insert("medium".to_string(), tier);
4951 let config = RouterConfig {
4952 mode: "round_robin".to_string(),
4953 agents: vec!["claude".to_string()],
4954 model_map,
4955 ..Default::default()
4956 };
4957 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
4958
4959 let active_repos: std::collections::HashSet<String> =
4963 ["active/repo".to_string(), "other-active/repo".to_string()]
4964 .into_iter()
4965 .collect();
4966 let mut routing_quota = RoutingTickQuota::new(1);
4967 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
4968 .await
4969 .unwrap();
4970
4971 assert_eq!(
4972 store.get(other_active_id).await.unwrap().status,
4973 TaskStatus::New,
4974 "global sweep run from a different active repo's tick must not route \
4975 another active repo's new tasks — that repo's own tick will route them"
4976 );
4977 assert_eq!(
4978 store.get(inactive_id).await.unwrap().status,
4979 TaskStatus::Routed,
4980 "global sweep must still route tasks from genuinely orphaned repos"
4981 );
4982 }
4983
4984 #[serial_test::serial(cooldown_state)]
4986 #[tokio::test]
4987 async fn global_sweep_skips_no_agent_tasks() {
4988 use crate::engine::router::{Router, RouterConfig};
4989 use crate::store::{TaskStatus, UpsertExternal};
4990
4991 crate::engine::cooldown::reset_global_state().await;
4992
4993 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4994
4995 let id = store
4996 .upsert_external(&UpsertExternal {
4997 repo: "inactive/repo",
4998 ext_id: "99",
4999 title: "No-agent task",
5000 body: "",
5001 author: "user",
5002 url: "https://github.com/inactive/repo/issues/99",
5003 labels: &["no-agent".to_string()],
5004 origin: "github",
5005 })
5006 .await
5007 .unwrap();
5008
5009 let mut model_map: std::collections::HashMap<
5010 String,
5011 std::collections::HashMap<String, Vec<String>>,
5012 > = std::collections::HashMap::new();
5013 let mut tier = std::collections::HashMap::new();
5014 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5015 model_map.insert("medium".to_string(), tier);
5016 let config = RouterConfig {
5017 mode: "round_robin".to_string(),
5018 agents: vec!["claude".to_string()],
5019 model_map,
5020 ..Default::default()
5021 };
5022 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5023
5024 let active_repos: std::collections::HashSet<String> =
5025 ["active/repo".to_string()].into_iter().collect();
5026 let mut routing_quota = RoutingTickQuota::new(1);
5027 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5028 .await
5029 .unwrap();
5030
5031 assert_eq!(
5032 store.get(id).await.unwrap().status,
5033 TaskStatus::New,
5034 "no-agent tasks must not be routed"
5035 );
5036 }
5037
5038 #[serial_test::serial(cooldown_state)]
5043 #[tokio::test]
5044 async fn global_sweep_honors_shared_routing_quota() {
5045 use crate::engine::router::{Router, RouterConfig};
5046 use crate::store::{TaskStatus, UpsertExternal};
5047
5048 crate::engine::cooldown::reset_global_state().await;
5049
5050 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5051 for ext_id in ["1", "2"] {
5052 store
5053 .upsert_external(&UpsertExternal {
5054 repo: "inactive/repo",
5055 ext_id,
5056 title: "Orphaned task",
5057 body: "",
5058 author: "user",
5059 url: &format!("https://github.com/inactive/repo/issues/{ext_id}"),
5060 labels: &[],
5061 origin: "github",
5062 })
5063 .await
5064 .unwrap();
5065 }
5066
5067 let mut model_map: std::collections::HashMap<
5068 String,
5069 std::collections::HashMap<String, Vec<String>>,
5070 > = std::collections::HashMap::new();
5071 let mut tier = std::collections::HashMap::new();
5072 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5073 model_map.insert("medium".to_string(), tier);
5074 let config = RouterConfig {
5075 mode: "round_robin".to_string(),
5076 agents: vec!["claude".to_string()],
5077 model_map,
5078 ..Default::default()
5079 };
5080 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5081
5082 let active_repos: std::collections::HashSet<String> =
5083 ["active/repo".to_string()].into_iter().collect();
5084 let mut routing_quota = RoutingTickQuota::new(1);
5085 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5086 .await
5087 .unwrap();
5088
5089 let all_tasks = store.list_all_global().await.unwrap();
5090 let routed = all_tasks
5091 .iter()
5092 .filter(|task| task.status == TaskStatus::Routed)
5093 .count();
5094 let still_new = all_tasks
5095 .iter()
5096 .filter(|task| task.status == TaskStatus::New)
5097 .count();
5098
5099 assert_eq!(routed, 1, "only one task may consume the shared quota");
5100 assert_eq!(still_new, 1, "remaining tasks wait for the next tick");
5101
5102 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5103 .await
5104 .unwrap();
5105 let after_exhausted = store.list_all_global().await.unwrap();
5106 assert_eq!(
5107 after_exhausted
5108 .iter()
5109 .filter(|task| task.status == TaskStatus::Routed)
5110 .count(),
5111 1,
5112 "an exhausted shared quota must not reset within the same tick"
5113 );
5114 }
5115
5116 #[tokio::test]
5133 async fn group_orphaned_routed_tasks_selects_inactive_repo_tasks() {
5134 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
5135
5136 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5137
5138 async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
5139 let id = store
5140 .upsert_external(&UpsertExternal {
5141 repo,
5142 ext_id,
5143 title: "Routed task",
5144 body: "",
5145 author: "user",
5146 url: &format!("https://github.com/{repo}/issues/{ext_id}"),
5147 labels: &[],
5148 origin: "github",
5149 })
5150 .await
5151 .unwrap();
5152 store
5153 .store_route(&StoreRoute {
5154 id,
5155 agent: "claude",
5156 model: Some("sonnet"),
5157 complexity: "medium",
5158 estimate: 0,
5159 reason: "test",
5160 profile: "{}",
5161 skills: "[]",
5162 })
5163 .await
5164 .unwrap();
5165 store.update_status(id, TaskStatus::Routed).await.unwrap();
5166 id
5167 }
5168
5169 let active_id = make_routed(&store, "active/repo", "1").await;
5170 let inactive_id_a = make_routed(&store, "inactive/repo", "2").await;
5171 let other_inactive_id = make_routed(&store, "other-inactive/repo", "3").await;
5172 let inactive_id_b = make_routed(&store, "inactive/repo", "4").await;
5173
5174 let all_routed = store
5175 .list_all_by_status_global(TaskStatus::Routed)
5176 .await
5177 .unwrap();
5178 let active_repos: std::collections::HashSet<String> =
5179 ["active/repo".to_string()].into_iter().collect();
5180 let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
5181
5182 assert!(
5183 !by_repo.contains_key("active/repo"),
5184 "global sweep must not select tasks for the active repo"
5185 );
5186 let inactive_ids: std::collections::HashSet<i64> = by_repo
5187 .get("inactive/repo")
5188 .expect("inactive/repo must be selected")
5189 .iter()
5190 .map(|t| t.id)
5191 .collect();
5192 assert_eq!(
5193 inactive_ids,
5194 [inactive_id_a, inactive_id_b].into_iter().collect(),
5195 "global sweep must group all orphaned tasks for a repo together"
5196 );
5197 assert_eq!(
5198 by_repo
5199 .get("other-inactive/repo")
5200 .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
5201 Some(vec![other_inactive_id])
5202 );
5203
5204 assert_eq!(
5207 store.get(active_id).await.unwrap().status,
5208 TaskStatus::Routed
5209 );
5210 }
5211
5212 #[tokio::test]
5217 async fn group_orphaned_routed_tasks_excludes_all_active_repos_not_just_current() {
5218 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
5219
5220 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5221
5222 async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
5223 let id = store
5224 .upsert_external(&UpsertExternal {
5225 repo,
5226 ext_id,
5227 title: "Routed task",
5228 body: "",
5229 author: "user",
5230 url: &format!("https://github.com/{repo}/issues/{ext_id}"),
5231 labels: &[],
5232 origin: "github",
5233 })
5234 .await
5235 .unwrap();
5236 store
5237 .store_route(&StoreRoute {
5238 id,
5239 agent: "claude",
5240 model: Some("sonnet"),
5241 complexity: "medium",
5242 estimate: 0,
5243 reason: "test",
5244 profile: "{}",
5245 skills: "[]",
5246 })
5247 .await
5248 .unwrap();
5249 store.update_status(id, TaskStatus::Routed).await.unwrap();
5250 id
5251 }
5252
5253 let repo_a_id = make_routed(&store, "active/repo-a", "1").await;
5254 let repo_b_id = make_routed(&store, "active/repo-b", "2").await;
5255 let orphaned_id = make_routed(&store, "inactive/repo", "3").await;
5256
5257 let all_routed = store
5258 .list_all_by_status_global(TaskStatus::Routed)
5259 .await
5260 .unwrap();
5261 let active_repos: std::collections::HashSet<String> =
5264 ["active/repo-a".to_string(), "active/repo-b".to_string()]
5265 .into_iter()
5266 .collect();
5267 let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
5268
5269 assert!(
5270 !by_repo.contains_key("active/repo-a"),
5271 "the current repo's own tasks must not be swept"
5272 );
5273 assert!(
5274 !by_repo.contains_key("active/repo-b"),
5275 "another active repo's tasks must not be swept during repo A's tick"
5276 );
5277 assert_eq!(
5278 by_repo
5279 .get("inactive/repo")
5280 .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
5281 Some(vec![orphaned_id]),
5282 "a genuinely inactive repo's tasks must still be swept"
5283 );
5284
5285 assert_eq!(
5287 store.get(repo_a_id).await.unwrap().status,
5288 TaskStatus::Routed
5289 );
5290 assert_eq!(
5291 store.get(repo_b_id).await.unwrap().status,
5292 TaskStatus::Routed
5293 );
5294 }
5295
5296 #[tokio::test]
5298 async fn global_dispatch_sweep_skips_no_agent_tasks() {
5299 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
5300
5301 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5302
5303 let id = store
5304 .upsert_external(&UpsertExternal {
5305 repo: "inactive/repo",
5306 ext_id: "99",
5307 title: "No-agent routed task",
5308 body: "",
5309 author: "user",
5310 url: "https://github.com/inactive/repo/issues/99",
5311 labels: &["no-agent".to_string()],
5312 origin: "github",
5313 })
5314 .await
5315 .unwrap();
5316 store
5317 .store_route(&StoreRoute {
5318 id,
5319 agent: "claude",
5320 model: Some("sonnet"),
5321 complexity: "medium",
5322 estimate: 0,
5323 reason: "test",
5324 profile: "{}",
5325 skills: "[]",
5326 })
5327 .await
5328 .unwrap();
5329 store.update_status(id, TaskStatus::Routed).await.unwrap();
5330
5331 let tmux = Arc::new(TmuxManager::new());
5336 let transport = Arc::new(Transport::new());
5337 let capture = Arc::new(CaptureService::new(transport));
5338 let semaphore = Arc::new(Semaphore::new(4));
5339 let (weight_tx, _weight_rx) = mpsc::channel(16);
5340 let dispatching = Arc::new(DashMap::new());
5341 let dispatch_mode = DispatchMode {
5342 is_degraded: false,
5343 healthy_agents: 1,
5344 threshold: 1,
5345 };
5346
5347 let active_repos: std::collections::HashSet<String> =
5348 ["active/repo".to_string()].into_iter().collect();
5349 dispatch_routed_tasks_global(
5350 &tmux,
5351 &capture,
5352 &semaphore,
5353 &weight_tx,
5354 dispatch_mode,
5355 &dispatching,
5356 &store,
5357 &std::collections::HashMap::new(),
5358 &active_repos,
5359 )
5360 .await
5361 .unwrap();
5362
5363 assert_eq!(
5364 store.get(id).await.unwrap().status,
5365 TaskStatus::Routed,
5366 "no-agent tasks must not be dispatched"
5367 );
5368 }
5369}