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| !t.labels.iter().any(|l| l == "no-agent"))
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| !t.labels.iter().any(|l| l == "no-agent"))
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?;
1983
1984 use crate::store::TaskStatus as DbStatus;
1986 let internal_routed = task_manager
1987 .list_internal_by_status(DbStatus::Routed)
1988 .await?;
1989 routed_tasks.extend(internal_routed);
1990
1991 let dispatchable: Vec<ExternalTask> = routed_tasks
1993 .into_iter()
1994 .filter(|t| !t.labels.iter().any(|l| l == "no-agent"))
1995 .collect();
1996
1997 dispatch_tasks_for_repo(
1998 backend,
1999 tmux,
2000 repo,
2001 runner,
2002 capture,
2003 semaphore,
2004 task_manager,
2005 weight_tx,
2006 dispatch_mode,
2007 dispatching,
2008 store,
2009 session_map,
2010 dispatchable,
2011 )
2012 .await
2013}
2014
2015#[allow(clippy::too_many_arguments)]
2021async fn dispatch_tasks_for_repo(
2022 backend: &Arc<dyn ExternalBackend>,
2023 tmux: &Arc<TmuxManager>,
2024 repo: &str,
2025 runner: &Arc<TaskRunner>,
2026 capture: &Arc<CaptureService>,
2027 semaphore: &Arc<Semaphore>,
2028 task_manager: &Arc<TaskManager>,
2029 weight_tx: &mpsc::Sender<WeightSignal>,
2030 dispatch_mode: DispatchMode,
2031 dispatching: &Arc<DashMap<String, String>>,
2032 store: &Arc<TaskStore>,
2033 session_map: &std::collections::HashMap<String, bool>,
2034 dispatchable: Vec<ExternalTask>,
2035) -> anyhow::Result<()> {
2036 if dispatchable.is_empty() {
2037 tracing::debug!(count = 0, "dispatchable tasks found");
2038 return Ok(());
2039 }
2040
2041 tracing::info!(count = dispatchable.len(), "dispatchable tasks found");
2042 if dispatch_mode.is_degraded {
2043 tracing::warn!(
2044 healthy_agents = dispatch_mode.healthy_agents,
2045 threshold = dispatch_mode.threshold,
2046 "degraded mode: using sequential dispatch"
2047 );
2048 }
2049
2050 let sequential_delay = if dispatch_mode.is_degraded {
2051 crate::engine::router::config::sequential_dispatch_delay_ms()
2052 } else {
2053 0
2054 };
2055
2056 for (idx, task) in dispatchable.into_iter().enumerate() {
2057 if idx > 0 && dispatch_mode.is_degraded {
2059 let delay_ms = sequential_delay;
2060 tracing::debug!(
2061 task_id = task.id.0,
2062 delay_ms = delay_ms,
2063 "sequential dispatch: waiting before dispatch"
2064 );
2065 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
2066 }
2067 let dispatch_key = format!("{}/{}", repo, task.id.0);
2074 {
2079 use dashmap::mapref::entry::Entry;
2080 match dispatching.entry(dispatch_key.clone()) {
2081 Entry::Occupied(existing) => {
2082 let existing_id = existing.get().clone();
2083 drop(existing); if existing_id == task.id.0 {
2085 tracing::debug!(
2086 task_id = task.id.0,
2087 "task already dispatching, skipping duplicate"
2088 );
2089 } else {
2090 tracing::warn!(
2091 task_id = task.id.0,
2092 existing_task_id = existing_id,
2093 dispatch_key,
2094 "dispatch key collision: unexpected task already holds this key"
2095 );
2096 }
2097 continue;
2098 }
2099 Entry::Vacant(slot) => {
2100 slot.insert(task.id.0.clone());
2101 tracing::info!(task_id = task.id.0, dispatch_key, "dispatch guard claimed");
2108 }
2109 }
2110 }
2111 let dispatch_guard = DispatchGuard::new(dispatching.clone(), dispatch_key.clone());
2113 let task_id = task.id.0.clone();
2114
2115 let route_result = match get_route_result(store, repo, &task_id).await {
2118 Ok(r) => Some(r),
2119 Err(RouteResultError::NoAgent { .. }) => {
2120 tracing::warn!(
2121 task_id,
2122 "routed task missing agent — resetting to new for re-routing (#1604)"
2123 );
2124 if let Err(e2) = task_manager.update_task_status(&task.id, Status::New).await {
2125 tracing::error!(task_id, error = %e2, "failed to reset task to new");
2126 }
2127 continue; }
2129 Err(e) => {
2130 tracing::warn!(task_id, error = %e, "get_route_result failed — dispatching without route info");
2131 None
2132 }
2133 };
2134
2135 if let Some(route) = route_result.as_ref() {
2141 let agent_cooled = is_agent_in_cooldown(&route.agent);
2142 let model_cooled = route
2143 .model
2144 .as_deref()
2145 .is_some_and(|model| is_model_in_cooldown(&route.agent, model));
2146 if agent_cooled || model_cooled {
2147 tracing::warn!(
2148 task_id,
2149 agent = route.agent,
2150 model = route.model.as_deref().unwrap_or(""),
2151 "routed agent/model entered cooldown after routing — deferring dispatch to next tick"
2152 );
2153 continue; }
2155 }
2156
2157 let session_name = tmux.session_name(repo, &task_id);
2159 if tmux
2160 .session_blocks_dispatch_from_map(&session_name, session_map)
2161 .await
2162 {
2163 tracing::warn!(
2164 task_id,
2165 session_name,
2166 "task has existing tmux session, skipping dispatch"
2167 );
2168 continue; }
2170
2171 let permit = match semaphore.clone().try_acquire_owned() {
2173 Ok(p) => p,
2174 Err(_) => {
2175 tracing::debug!("all parallel slots busy, skipping remaining tasks");
2176 break; }
2178 };
2179
2180 let set_in_progress_result = task_manager
2182 .update_task_status(&task.id, Status::InProgress)
2183 .await;
2184 if let Err(e) = set_in_progress_result {
2185 tracing::error!(task_id, ?e, "failed to set in_progress, skipping dispatch");
2186 drop(permit);
2187 continue; }
2189 tracing::info!(task_id, "dispatching task");
2190
2191 capture
2193 .register_session(repo, &task_id, &session_name)
2194 .await;
2195
2196 let runner = runner.clone();
2198 let backend = backend.clone();
2199 let tmux = tmux.clone();
2200 let capture = capture.clone();
2201 let task_id_for_cleanup = task_id.clone();
2202 let task_owned = task;
2203 let weight_tx = weight_tx.clone();
2204 let repo_owned = repo.to_string();
2205 let task_manager_for_spawn = task_manager.clone();
2206 let store_for_spawn = store.clone();
2207
2208 let repo_ctx = repo_owned.clone();
2209 tokio::spawn(REPO_CONTEXT.scope(repo_ctx, async move {
2210 let _dispatch_guard = dispatch_guard; let dispatch_start = std::time::Instant::now();
2212 match runner
2213 .run_with_context(&task_owned, &backend, &tmux, route_result.as_ref())
2214 .await
2215 {
2216 Ok(signal) => {
2217 tracing::info!(task_id, "task runner completed");
2218
2219 let duration = dispatch_start.elapsed().as_secs_f64();
2220
2221 let enable_review = config::get("workflow.enable_review_agent")
2227 .map(|v| v != "false")
2228 .unwrap_or(true);
2229 let display_status = match &signal {
2230 WeightSignal::Success { .. } if enable_review => {
2231 let has_pr = match store_for_spawn
2235 .resolve_task_id(&repo_owned, &task_id)
2236 .await
2237 {
2238 Ok(Some(store_id)) => match store_for_spawn
2239 .get(store_id)
2240 .await
2241 {
2242 Ok(t) => t.pr_number.is_some(),
2243 Err(e) => {
2244 tracing::warn!(task_id, err = %e, "store.get() failed — defaulting to needs_review");
2245 true
2246 }
2247 },
2248 Ok(None) => false,
2249 Err(e) => {
2250 tracing::warn!(task_id, err = %e, "failed to check PR status — defaulting to needs_review");
2251 true
2252 }
2253 };
2254 if has_pr {
2255 "needs_review"
2256 } else {
2257 "done"
2258 }
2259 }
2260 WeightSignal::Success { .. } => "done",
2261 WeightSignal::RateLimited { .. } => "new",
2262 WeightSignal::Blocked => "blocked",
2263 WeightSignal::Rerouted => "new",
2268 WeightSignal::None if enable_review => "needs_review",
2273 WeightSignal::None => "done",
2274 };
2275
2276 let _ = weight_tx.send(signal).await;
2278
2279 if display_status == "needs_review" {
2283 if let Err(e) = task_manager_for_spawn
2286 .update_task_status_with_duration(
2287 &ExternalId(task_id.clone()),
2288 Status::NeedsReview,
2289 Some(duration),
2290 )
2291 .await
2292 {
2293 tracing::error!(task_id, err = %e, "update_task_status(NeedsReview) failed — task may be stuck");
2294 }
2295 } else {
2297 let final_status = match display_status {
2299 "done" => Status::Done,
2300 "blocked" => Status::Blocked,
2301 _ => Status::New,
2302 };
2303 if let Err(e) = task_manager_for_spawn
2304 .update_task_status_with_duration(
2305 &ExternalId(task_id.clone()),
2306 final_status,
2307 Some(duration),
2308 )
2309 .await
2310 {
2311 tracing::warn!(task_id, ?e, "failed to update task status after completion");
2312 }
2313 }
2314 }
2315 Err(e) => {
2316 tracing::error!(task_id, ?e, "task runner failed");
2317 let duration = dispatch_start.elapsed().as_secs_f64();
2318 if is_internal_id(&task_id) {
2319 if let Err(ue) = task_manager_for_spawn
2321 .update_task_status_with_duration(
2322 &ExternalId(task_id.clone()),
2323 Status::NeedsReview,
2324 Some(duration),
2325 )
2326 .await
2327 {
2328 tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
2329 }
2330 } else {
2331 if let Err(comment_err) = backend
2333 .post_comment(
2334 &ExternalId(task_id.clone()),
2335 &format!(
2336 "[{}] error: task runner failed: {e}{}",
2337 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
2338 crate::engine::orch_footer()
2339 ),
2340 )
2341 .await
2342 {
2343 tracing::warn!(
2344 task_id,
2345 ?comment_err,
2346 "failed to post error comment to GitHub"
2347 );
2348 }
2349 if let Err(ue) = task_manager_for_spawn
2353 .update_task_status_with_duration(
2354 &ExternalId(task_id.clone()),
2355 Status::NeedsReview,
2356 Some(duration),
2357 )
2358 .await
2359 {
2360 tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
2361 }
2362 }
2363 }
2364 }
2365
2366 capture.unregister_session(&repo_owned, &task_id_for_cleanup).await;
2368
2369 drop(permit);
2371 }));
2373 }
2374 Ok(())
2375}
2376
2377fn group_orphaned_routed_tasks(
2384 all_routed: Vec<crate::store::Task>,
2385 active_repos: &std::collections::HashSet<String>,
2386) -> std::collections::HashMap<String, Vec<crate::store::Task>> {
2387 let mut by_repo: std::collections::HashMap<String, Vec<crate::store::Task>> =
2388 std::collections::HashMap::new();
2389 for task in all_routed
2390 .into_iter()
2391 .filter(|t| !active_repos.contains(&t.repo))
2392 .filter(|t| !t.labels.iter().any(|l| l == "no-agent"))
2393 {
2394 by_repo.entry(task.repo.clone()).or_default().push(task);
2395 }
2396 by_repo
2397}
2398
2399#[allow(clippy::too_many_arguments)]
2419pub(crate) async fn dispatch_routed_tasks_global(
2420 tmux: &Arc<TmuxManager>,
2421 capture: &Arc<CaptureService>,
2422 semaphore: &Arc<Semaphore>,
2423 weight_tx: &mpsc::Sender<WeightSignal>,
2424 dispatch_mode: DispatchMode,
2425 dispatching: &Arc<DashMap<String, String>>,
2426 store: &Arc<TaskStore>,
2427 session_map: &std::collections::HashMap<String, bool>,
2428 active_repos: &std::collections::HashSet<String>,
2429) -> anyhow::Result<()> {
2430 use crate::store::TaskStatus;
2431
2432 let all_routed = store.list_all_by_status_global(TaskStatus::Routed).await?;
2433 let by_repo = group_orphaned_routed_tasks(all_routed, active_repos);
2434
2435 if by_repo.is_empty() {
2436 return Ok(());
2437 }
2438
2439 tracing::info!(
2440 repos = by_repo.len(),
2441 "global dispatch sweep: found routed tasks from inactive repos"
2442 );
2443
2444 for (repo, tasks) in by_repo {
2445 let backend: Arc<dyn ExternalBackend> = match crate::backends::github::GitHubBackend::new(
2446 repo.clone(),
2447 ) {
2448 Ok(b) => Arc::new(b),
2449 Err(e) => {
2450 tracing::warn!(repo = %repo, err = %e, "global sweep: failed to construct backend, skipping repo");
2451 continue;
2452 }
2453 };
2454 let task_manager = Arc::new(TaskManager::with_store(
2455 backend.clone(),
2456 store.clone(),
2457 repo.clone(),
2458 ));
2459 let runner = Arc::new(TaskRunner::new(repo.clone()).with_store(store.clone()));
2460 let dispatchable: Vec<ExternalTask> = tasks
2461 .iter()
2462 .map(crate::engine::tasks::store_task_to_external)
2463 .collect();
2464
2465 if let Err(e) = dispatch_tasks_for_repo(
2466 &backend,
2467 tmux,
2468 &repo,
2469 &runner,
2470 capture,
2471 semaphore,
2472 &task_manager,
2473 weight_tx,
2474 dispatch_mode,
2475 dispatching,
2476 store,
2477 session_map,
2478 dispatchable,
2479 )
2480 .await
2481 {
2482 tracing::warn!(repo = %repo, err = %e, "global sweep: dispatch failed for repo");
2483 }
2484 }
2485
2486 Ok(())
2487}
2488
2489pub(crate) async fn tick_unblock_parents(
2491 backend: &Arc<dyn ExternalBackend>,
2492 task_manager: &Arc<TaskManager>,
2493 store: &Arc<TaskStore>,
2494 repo: &str,
2495) -> anyhow::Result<()> {
2496 let blocked = task_manager.list_all_by_status(Status::Blocked).await?;
2497
2498 let store_blocked = store
2502 .list_by_status(repo, crate::store::TaskStatus::Blocked)
2503 .await?;
2504 let has_block_reason: std::collections::HashSet<String> = store_blocked
2505 .iter()
2506 .filter(|t| t.block_reason.is_some())
2507 .filter_map(|t| t.external_id.clone())
2508 .collect();
2509
2510 let handles: Vec<_> = blocked
2514 .into_iter()
2515 .filter(|task| {
2516 if has_block_reason.contains(&task.id.0) {
2517 tracing::debug!(
2518 task_id = task.id.0,
2519 "skipping blocked task with known block_reason"
2520 );
2521 false
2522 } else {
2523 true
2524 }
2525 })
2526 .map(|task| {
2527 let backend_clone = Arc::clone(backend);
2528 let task_manager_clone = Arc::clone(task_manager);
2529 let store_clone = Arc::clone(store);
2530 let repo_owned = repo.to_string();
2531 tokio::spawn(async move {
2532 let task_id = &task.id;
2533
2534 let mut children: Vec<ExternalId> = Vec::new();
2535 let mut child_statuses: std::collections::HashMap<
2536 String,
2537 crate::store::TaskStatus,
2538 > = std::collections::HashMap::new();
2539
2540 match store_clone.resolve_task_id(&repo_owned, &task_id.0).await {
2541 Ok(Some(parent_store_id)) => {
2542 match store_clone.list_children(&repo_owned, parent_store_id).await {
2543 Ok(store_children) => {
2544 for child in store_children {
2545 let Some(child_external_id) = child.external_id else {
2546 continue;
2547 };
2548 child_statuses.insert(child_external_id.clone(), child.status);
2549 children.push(ExternalId(child_external_id));
2550 }
2551 }
2552 Err(e) => {
2553 tracing::debug!(
2554 task_id = task_id.0,
2555 ?e,
2556 "failed to list store-linked children"
2557 );
2558 }
2559 }
2560 }
2561 Ok(None) => {}
2562 Err(e) => {
2563 tracing::debug!(
2564 task_id = task_id.0,
2565 ?e,
2566 "failed to resolve blocked task in store for child lookup"
2567 );
2568 }
2569 }
2570
2571 if !is_internal_id(&task_id.0) {
2572 match backend_clone.get_sub_issues(task_id).await {
2573 Ok(ids) => {
2574 let mut seen: std::collections::HashSet<String> =
2575 children.iter().map(|child| child.0.clone()).collect();
2576 for id in ids {
2577 if seen.insert(id.0.clone()) {
2578 children.push(id);
2579 }
2580 }
2581 }
2582 Err(e) => {
2583 tracing::debug!(task_id = task_id.0, ?e, "failed to get sub-issues");
2584 }
2585 }
2586 }
2587
2588 if children.is_empty() {
2590 return;
2591 }
2592
2593 let mut all_done = true;
2596 let child_ext_strs: Vec<String> =
2598 children.iter().map(|c| c.0.clone()).collect();
2599 let child_exts: Vec<&str> =
2600 child_ext_strs.iter().map(|s| s.as_str()).collect();
2601 if let Ok(map) = store_clone
2603 .get_statuses_by_external_ids(&repo_owned, &child_exts)
2604 .await
2605 {
2606 for (k, v) in map.into_iter() {
2607 child_statuses.insert(k, v);
2608 }
2609 } else {
2610 tracing::debug!(
2611 task_id = task_id.0,
2612 "failed to batch-query store for child statuses; falling back to per-child lookups"
2613 );
2614 }
2615
2616 for child_id in &children {
2617 if let Some(status) = child_statuses.get(&child_id.0) {
2619 if *status != crate::store::TaskStatus::Done {
2620 all_done = false;
2621 break;
2622 }
2623 continue;
2624 }
2625
2626 match backend_clone.get_task(child_id).await {
2628 Ok(child) => {
2629 if !child.labels.iter().any(|l| l == Status::Done.as_label()) {
2630 all_done = false;
2631 break;
2632 }
2633 }
2634 Err(e) => {
2635 tracing::debug!(task_id = task_id.0, child = %child_id.0, ?e, "failed to fetch child task from backend — treating as not done");
2636 all_done = false;
2637 break;
2638 }
2639 }
2640 }
2641
2642 if all_done {
2643 tracing::info!(
2644 task_id = task_id.0,
2645 children = children.len(),
2646 "all children done, unblocking parent"
2647 );
2648 if let Err(e) =
2649 task_manager_clone.update_task_status(task_id, Status::New).await
2650 {
2651 tracing::warn!(task_id = task_id.0, ?e, "failed to unblock parent");
2652 }
2653 }
2654 })
2655 })
2656 .collect();
2657
2658 for handle in handles {
2659 if let Err(e) = handle.await {
2660 tracing::warn!(?e, "phase 4 sub-issue check panicked");
2661 }
2662 }
2663 Ok(())
2664}
2665
2666pub(crate) async fn tick_job_scheduler(
2668 jobs_path: &std::path::Path,
2669 backend: &Arc<dyn ExternalBackend>,
2670 store: Option<&Arc<TaskStore>>,
2671 repo: &str,
2672 transport: Option<&Arc<crate::channels::transport::Transport>>,
2673) -> anyhow::Result<()> {
2674 jobs::tick(jobs_path, backend, store, repo, transport).await
2675}
2676
2677#[allow(clippy::too_many_arguments)]
2687pub(crate) async fn tick(
2688 backend: &Arc<dyn ExternalBackend>,
2689 tmux: &Arc<TmuxManager>,
2690 repo: &str,
2691 runner: &Arc<TaskRunner>,
2692 capture: &Arc<CaptureService>,
2693 semaphore: &Arc<Semaphore>,
2694 config: &EngineConfig,
2695 jobs_path: &std::path::Path,
2696 router: &mut Router,
2697 task_manager: &Arc<TaskManager>,
2698 weight_tx: &mpsc::Sender<WeightSignal>,
2699 dispatching: &Arc<DashMap<String, String>>,
2700 store: &Arc<TaskStore>,
2701 transport: Option<&Arc<crate::channels::transport::Transport>>,
2702 active_repos: &std::collections::HashSet<String>,
2703 auto_merge_in_flight: &Arc<DashSet<String>>,
2704 routing_quota: &mut RoutingTickQuota,
2705) -> anyhow::Result<()> {
2706 let _tick_span = tracing::info_span!("engine.tick").entered();
2707
2708 startup_cleanup(tmux).await;
2710
2711 tick_check_session_completions(tmux, repo, capture, store).await?;
2712 tick_detect_silent_agents(tmux, repo, capture, backend, task_manager, config, store).await?;
2713 let session_map = tmux.batch_session_active().await;
2716 tick_recover_stuck_tasks(
2717 backend,
2718 tmux,
2719 repo,
2720 task_manager,
2721 config,
2722 store,
2723 &session_map,
2724 router,
2725 dispatching,
2726 auto_merge_in_flight,
2727 )
2728 .await?;
2729 tick_route_tasks(backend, task_manager, router, store, repo, routing_quota).await?;
2730 if let Err(e) = route_new_tasks_global(router, store, active_repos, routing_quota).await {
2734 tracing::warn!(err = %e, "global new-task routing sweep failed");
2735 }
2736 let dispatch_mode = dispatch_mode_from_router(router);
2737 tick_dispatch_tasks(
2738 backend,
2739 tmux,
2740 repo,
2741 runner,
2742 capture,
2743 semaphore,
2744 task_manager,
2745 weight_tx,
2746 dispatch_mode,
2747 dispatching,
2748 store,
2749 &session_map,
2750 )
2751 .await?;
2752 if let Err(e) = dispatch_routed_tasks_global(
2758 tmux,
2759 capture,
2760 semaphore,
2761 weight_tx,
2762 dispatch_mode,
2763 dispatching,
2764 store,
2765 &session_map,
2766 active_repos,
2767 )
2768 .await
2769 {
2770 tracing::warn!(err = %e, "global routed-task dispatch sweep failed");
2771 }
2772 tick_unblock_parents(backend, task_manager, store, repo).await?;
2773 if let Err(e) = tick_job_scheduler(jobs_path, backend, Some(store), repo, transport).await {
2774 tracing::error!(?e, "job scheduler tick failed");
2775 }
2776 Ok(())
2777}
2778
2779#[cfg(test)]
2780mod tests {
2781 use super::*;
2782 use crate::backends::{ExternalId, ExternalTask, Mention, Status};
2783 use crate::channels::transport::Transport;
2784 use crate::engine::tasks::TaskManager;
2785 use crate::store::TaskStore;
2786 use crate::tmux::{SessionGuard, TmuxManager};
2787 use async_trait::async_trait;
2788 use std::sync::{Arc, Mutex};
2789
2790 fn cooled_review_router_for_test() -> Router {
2794 Router::new_for_test(
2795 crate::engine::router::RouterConfig::default(),
2796 vec!["claude".to_string()],
2797 )
2798 }
2799
2800 struct MockBackend {
2804 blocked_tasks: Vec<ExternalTask>,
2806 sub_issues: std::collections::HashMap<String, Vec<ExternalId>>,
2808 tasks_by_id: std::collections::HashMap<String, ExternalTask>,
2810 get_sub_issues_calls: Arc<Mutex<Vec<String>>>,
2812 status_updates: Arc<Mutex<Vec<(String, Status)>>>,
2814 removed_labels: Arc<Mutex<Vec<(String, String)>>>,
2816 posted_comments: Arc<Mutex<Vec<(String, String)>>>,
2818 }
2819
2820 impl MockBackend {
2821 fn new() -> Self {
2822 Self {
2823 blocked_tasks: vec![],
2824 sub_issues: Default::default(),
2825 tasks_by_id: Default::default(),
2826 get_sub_issues_calls: Arc::new(Mutex::new(vec![])),
2827 status_updates: Arc::new(Mutex::new(vec![])),
2828 removed_labels: Arc::new(Mutex::new(vec![])),
2829 posted_comments: Arc::new(Mutex::new(vec![])),
2830 }
2831 }
2832 }
2833
2834 fn make_task(id: &str, labels: &[&str]) -> ExternalTask {
2835 ExternalTask {
2836 id: ExternalId(id.to_string()),
2837 title: format!("Task {id}"),
2838 body: "".to_string(),
2839 state: "open".to_string(),
2840 labels: labels.iter().map(|s| s.to_string()).collect(),
2841 author: "bot".to_string(),
2842 created_at: "2026-01-01T00:00:00Z".to_string(),
2843 updated_at: "2026-01-01T00:00:00Z".to_string(),
2844 url: format!("https://github.com/test/test/issues/{id}"),
2845 }
2846 }
2847
2848 #[async_trait]
2849 impl ExternalBackend for MockBackend {
2850 fn name(&self) -> &str {
2851 "mock"
2852 }
2853 async fn create_task(
2854 &self,
2855 _title: &str,
2856 _body: &str,
2857 _labels: &[String],
2858 ) -> anyhow::Result<ExternalId> {
2859 Ok(ExternalId("new".to_string()))
2860 }
2861 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
2862 self.tasks_by_id
2863 .get(&id.0)
2864 .cloned()
2865 .ok_or_else(|| anyhow::anyhow!("task not found: {}", id.0))
2866 }
2867 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
2868 if status == Status::Blocked {
2869 Ok(self.blocked_tasks.clone())
2870 } else {
2871 Ok(vec![])
2872 }
2873 }
2874 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2875 Ok(vec![])
2876 }
2877 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
2878 self.posted_comments
2879 .lock()
2880 .unwrap()
2881 .push((_id.0.clone(), _body.to_string()));
2882 Ok(())
2883 }
2884 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2885 Ok(())
2886 }
2887 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2888 self.removed_labels
2889 .lock()
2890 .unwrap()
2891 .push((_id.0.clone(), _label.to_string()));
2892 Ok(())
2893 }
2894 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2895 self.get_sub_issues_calls.lock().unwrap().push(id.0.clone());
2896 Ok(self.sub_issues.get(&id.0).cloned().unwrap_or_default())
2897 }
2898 async fn create_sub_task(
2899 &self,
2900 _parent: &ExternalId,
2901 _title: &str,
2902 _body: &str,
2903 _labels: &[String],
2904 ) -> anyhow::Result<ExternalId> {
2905 Ok(ExternalId("child".to_string()))
2906 }
2907 async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
2908 Ok(())
2909 }
2910 async fn has_open_issue_with_title(
2911 &self,
2912 _title: &str,
2913 _label: &str,
2914 ) -> anyhow::Result<bool> {
2915 Ok(false)
2916 }
2917 async fn health_check(&self) -> anyhow::Result<()> {
2918 Ok(())
2919 }
2920 async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
2921 Ok(false)
2922 }
2923 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
2924 Ok(Some("testbot".to_string()))
2925 }
2926 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
2927 Ok(vec![])
2928 }
2929 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
2930 self.status_updates
2931 .lock()
2932 .unwrap()
2933 .push((id.0.clone(), status));
2934 Ok(())
2935 }
2936 }
2937
2938 fn make_task_manager(backend: Arc<dyn ExternalBackend>) -> Arc<TaskManager> {
2939 Arc::new(TaskManager::new(backend))
2940 }
2941
2942 #[tokio::test]
2945 async fn unblock_parents_unblocks_when_all_children_done() {
2946 let mut mock = MockBackend::new();
2947
2948 let parent = make_task("10", &["status:blocked"]);
2950 mock.blocked_tasks.push(parent.clone());
2951 mock.sub_issues.insert(
2952 "10".to_string(),
2953 vec![ExternalId("11".to_string()), ExternalId("12".to_string())],
2954 );
2955 mock.tasks_by_id
2956 .insert("11".to_string(), make_task("11", &["status:done"]));
2957 mock.tasks_by_id
2958 .insert("12".to_string(), make_task("12", &["status:done"]));
2959
2960 let status_updates = mock.status_updates.clone();
2961 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
2962 let task_manager = make_task_manager(backend.clone());
2963 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2964
2965 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
2966 .await
2967 .unwrap();
2968
2969 let updates = status_updates.lock().unwrap();
2970 assert_eq!(updates.len(), 1, "parent should be unblocked");
2971 assert_eq!(updates[0], ("10".to_string(), Status::New));
2972 }
2973
2974 #[tokio::test]
2975 async fn unblock_parents_skips_when_child_not_done() {
2976 let mut mock = MockBackend::new();
2977
2978 let parent = make_task("20", &["status:blocked"]);
2979 mock.blocked_tasks.push(parent.clone());
2980 mock.sub_issues.insert(
2981 "20".to_string(),
2982 vec![ExternalId("21".to_string()), ExternalId("22".to_string())],
2983 );
2984 mock.tasks_by_id
2986 .insert("21".to_string(), make_task("21", &["status:done"]));
2987 mock.tasks_by_id
2988 .insert("22".to_string(), make_task("22", &["status:in_progress"]));
2989
2990 let status_updates = mock.status_updates.clone();
2991 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
2992 let task_manager = make_task_manager(backend.clone());
2993 let store = Arc::new(TaskStore::open_memory().await.unwrap());
2994
2995 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
2996 .await
2997 .unwrap();
2998
2999 let updates = status_updates.lock().unwrap();
3000 assert!(
3001 updates.is_empty(),
3002 "should not unblock when a child is still running"
3003 );
3004 }
3005
3006 #[tokio::test]
3007 async fn unblock_parents_skips_task_with_no_children() {
3008 let mut mock = MockBackend::new();
3009
3010 let parent = make_task("30", &["status:blocked"]);
3012 mock.blocked_tasks.push(parent.clone());
3013 let status_updates = mock.status_updates.clone();
3016 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3017 let task_manager = make_task_manager(backend.clone());
3018 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3019
3020 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3021 .await
3022 .unwrap();
3023
3024 let updates = status_updates.lock().unwrap();
3025 assert!(
3026 updates.is_empty(),
3027 "should not unblock tasks with no sub-issues"
3028 );
3029 }
3030
3031 #[tokio::test]
3032 async fn unblock_parents_skips_task_with_block_reason_in_store() {
3033 let mut mock = MockBackend::new();
3036
3037 let parent = make_task("60", &["status:blocked"]);
3039 mock.blocked_tasks.push(parent.clone());
3040 mock.sub_issues
3043 .insert("60".to_string(), vec![ExternalId("61".to_string())]);
3044 mock.tasks_by_id
3045 .insert("61".to_string(), make_task("61", &["status:done"]));
3046
3047 let status_updates = mock.status_updates.clone();
3048 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3049 let task_manager = make_task_manager(backend.clone());
3050
3051 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3053 let task_id = store
3054 .upsert_external(&crate::store::UpsertExternal {
3055 repo: "test/repo",
3056 ext_id: "60",
3057 title: "Task 60",
3058 body: "",
3059 author: "bot",
3060 url: "https://github.com/test/test/issues/60",
3061 labels: &[],
3062 origin: "github",
3063 })
3064 .await
3065 .unwrap();
3066 store
3067 .update_status(task_id, crate::store::TaskStatus::Blocked)
3068 .await
3069 .unwrap();
3070 store
3071 .set_block_reason(task_id, Some("CI failure limit reached"))
3072 .await
3073 .unwrap();
3074
3075 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3076 .await
3077 .unwrap();
3078
3079 let updates = status_updates.lock().unwrap();
3080 assert!(
3081 updates.is_empty(),
3082 "task with block_reason should be skipped — no API call, no unblock"
3083 );
3084 }
3085
3086 #[tokio::test]
3087 async fn unblock_parents_unblocks_internal_parent_from_store_children() {
3088 let mock = MockBackend::new();
3089 let get_sub_issues_calls = mock.get_sub_issues_calls.clone();
3090 let status_updates = mock.status_updates.clone();
3091 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3092 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3093 let task_manager = Arc::new(TaskManager::with_store(
3094 backend.clone(),
3095 store.clone(),
3096 "test/repo".to_string(),
3097 ));
3098
3099 let parent_id = store
3100 .create_internal("test/repo", "Parent", "", "job", "daily", None)
3101 .await
3102 .unwrap();
3103 store
3104 .update_status(parent_id, crate::store::TaskStatus::Blocked)
3105 .await
3106 .unwrap();
3107 let child_id = store
3108 .create(&crate::store::NewTask {
3109 external_id: Some("91".to_string()),
3110 repo: "test/repo".to_string(),
3111 origin: "github".to_string(),
3112 title: "Child".to_string(),
3113 body: String::new(),
3114 source: String::new(),
3115 source_id: String::new(),
3116 author: String::new(),
3117 url: String::new(),
3118 labels: vec![],
3119 parent_id: Some(parent_id),
3120 })
3121 .await
3122 .unwrap();
3123 store
3124 .update_status(child_id, crate::store::TaskStatus::Done)
3125 .await
3126 .unwrap();
3127
3128 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3129 .await
3130 .unwrap();
3131
3132 let parent = store.get(parent_id).await.unwrap();
3133 assert_eq!(parent.status, crate::store::TaskStatus::New);
3134 assert!(
3135 status_updates.lock().unwrap().is_empty(),
3136 "internal parent should be updated in store without backend mirroring"
3137 );
3138 assert!(
3139 get_sub_issues_calls.lock().unwrap().is_empty(),
3140 "internal parent should not query backend sub-issues"
3141 );
3142 }
3143
3144 #[cfg(test)]
3148 async fn set_task_updated_at_past(store: &TaskStore, task_id: i64) {
3149 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
3150 .bind(task_id)
3151 .execute(store.pool())
3152 .await
3153 .unwrap();
3154 }
3155
3156 #[tokio::test]
3157 async fn recover_stuck_tasks_resets_external_in_review_to_needs_review() {
3158 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3159 let mock = MockBackend::new();
3160 let status_updates = mock.status_updates.clone();
3161 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3162 let task_manager = Arc::new(TaskManager::with_store(
3163 backend.clone(),
3164 store.clone(),
3165 "owner/repo".to_string(),
3166 ));
3167 let tmux = Arc::new(TmuxManager::new());
3168 let config = EngineConfig {
3169 no_session_stuck_timeout: 600,
3170 stuck_timeout: 1800,
3171 ..EngineConfig::default()
3172 };
3173
3174 let id = store
3176 .upsert_external(&crate::store::UpsertExternal {
3177 repo: "owner/repo",
3178 ext_id: "99",
3179 title: "Stuck InReview",
3180 body: "",
3181 author: "",
3182 url: "",
3183 labels: &[],
3184 origin: "github",
3185 })
3186 .await
3187 .unwrap();
3188 store
3189 .update_status(id, crate::store::TaskStatus::InReview)
3190 .await
3191 .unwrap();
3192 set_review_session_expected(&store, "owner/repo", "99", true).await;
3193 set_task_updated_at_past(&store, id).await;
3194
3195 tick_recover_stuck_tasks(
3196 &backend,
3197 &tmux,
3198 "owner/repo",
3199 &task_manager,
3200 &config,
3201 &store,
3202 &std::collections::HashMap::new(),
3203 &cooled_review_router_for_test(),
3204 &Arc::new(DashMap::new()),
3205 &Arc::new(DashSet::new()),
3206 )
3207 .await
3208 .unwrap();
3209
3210 let updates = status_updates.lock().unwrap();
3211 assert_eq!(updates.len(), 1, "stuck InReview task should be recovered");
3212 assert_eq!(updates[0].0, "99");
3213 assert_eq!(updates[0].1, Status::NeedsReview);
3214 }
3215
3216 #[tokio::test]
3217 async fn recover_stuck_tasks_skips_recent_in_review() {
3218 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3219 let mock = MockBackend::new();
3220 let status_updates = mock.status_updates.clone();
3221 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3222 let task_manager = Arc::new(TaskManager::with_store(
3223 backend.clone(),
3224 store.clone(),
3225 "owner/repo".to_string(),
3226 ));
3227 let tmux = Arc::new(TmuxManager::new());
3228 let config = EngineConfig {
3229 no_session_stuck_timeout: 600, stuck_timeout: 1800,
3231 ..EngineConfig::default()
3232 };
3233
3234 let id = store
3236 .upsert_external(&crate::store::UpsertExternal {
3237 repo: "owner/repo",
3238 ext_id: "100",
3239 title: "Recent InReview",
3240 body: "",
3241 author: "",
3242 url: "",
3243 labels: &[],
3244 origin: "github",
3245 })
3246 .await
3247 .unwrap();
3248 store
3249 .update_status(id, crate::store::TaskStatus::InReview)
3250 .await
3251 .unwrap();
3252 tick_recover_stuck_tasks(
3255 &backend,
3256 &tmux,
3257 "owner/repo",
3258 &task_manager,
3259 &config,
3260 &store,
3261 &std::collections::HashMap::new(),
3262 &cooled_review_router_for_test(),
3263 &Arc::new(DashMap::new()),
3264 &Arc::new(DashSet::new()),
3265 )
3266 .await
3267 .unwrap();
3268
3269 let updates = status_updates.lock().unwrap();
3270 assert!(
3271 updates.is_empty(),
3272 "recent InReview task should not be recovered"
3273 );
3274 }
3275
3276 #[tokio::test]
3277 async fn recover_stuck_tasks_resets_internal_in_review_to_needs_review() {
3278 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3279 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3280 let task_manager = Arc::new(TaskManager::with_store(
3281 backend.clone(),
3282 store.clone(),
3283 "owner/repo".to_string(),
3284 ));
3285 let tmux = Arc::new(TmuxManager::new());
3286 let config = EngineConfig {
3287 no_session_stuck_timeout: 600,
3288 stuck_timeout: 1800,
3289 ..EngineConfig::default()
3290 };
3291
3292 let id = store
3294 .create_internal("owner/repo", "Internal InReview", "", "cron", "1", None)
3295 .await
3296 .unwrap();
3297 store
3298 .update_status(id, crate::store::TaskStatus::InReview)
3299 .await
3300 .unwrap();
3301 set_review_session_expected(&store, "owner/repo", "internal:1", true).await;
3302 set_task_updated_at_past(&store, id).await;
3303
3304 tick_recover_stuck_tasks(
3305 &backend,
3306 &tmux,
3307 "owner/repo",
3308 &task_manager,
3309 &config,
3310 &store,
3311 &std::collections::HashMap::new(),
3312 &cooled_review_router_for_test(),
3313 &Arc::new(DashMap::new()),
3314 &Arc::new(DashSet::new()),
3315 )
3316 .await
3317 .unwrap();
3318
3319 let task = store.get(id).await.unwrap();
3320 assert_eq!(
3321 task.status,
3322 crate::store::TaskStatus::NeedsReview,
3323 "stuck internal InReview task should be reset to NeedsReview"
3324 );
3325 }
3326
3327 #[tokio::test]
3328 async fn recover_stuck_tasks_resets_cross_repo_external_in_progress_to_new() {
3329 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3330 let mock = MockBackend::new();
3331 let status_updates = mock.status_updates.clone();
3332 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3333 let task_manager = Arc::new(TaskManager::with_store(
3334 backend.clone(),
3335 store.clone(),
3336 "owner/repo".to_string(),
3337 ));
3338 let tmux = Arc::new(TmuxManager::new());
3339 let config = EngineConfig {
3340 no_session_stuck_timeout: 600,
3341 stuck_timeout: 1800,
3342 ..EngineConfig::default()
3343 };
3344
3345 let id = store
3347 .upsert_external(&crate::store::UpsertExternal {
3348 repo: "owner/other",
3349 ext_id: "777",
3350 title: "Cross-repo stuck task",
3351 body: "",
3352 author: "",
3353 url: "",
3354 labels: &[],
3355 origin: "github",
3356 })
3357 .await
3358 .unwrap();
3359 store
3360 .update_status(id, crate::store::TaskStatus::InProgress)
3361 .await
3362 .unwrap();
3363 set_task_updated_at_past(&store, id).await;
3364
3365 tick_recover_stuck_tasks(
3366 &backend,
3367 &tmux,
3368 "owner/repo",
3369 &task_manager,
3370 &config,
3371 &store,
3372 &std::collections::HashMap::new(),
3373 &cooled_review_router_for_test(),
3374 &Arc::new(DashMap::new()),
3375 &Arc::new(DashSet::new()),
3376 )
3377 .await
3378 .unwrap();
3379
3380 let task = store.get(id).await.unwrap();
3381 assert_eq!(
3382 task.status,
3383 crate::store::TaskStatus::New,
3384 "cross-repo stale in_progress task should be reset to New"
3385 );
3386 assert_eq!(task.route_attempts, 0, "route attempts should be cleared");
3387 assert!(task.agent.is_none(), "agent should be cleared");
3388 assert!(task.model.is_none(), "model should be cleared");
3389
3390 assert!(
3392 status_updates.lock().unwrap().is_empty(),
3393 "cross-repo recovery should not call backend update_status"
3394 );
3395 }
3396
3397 #[tokio::test]
3398 async fn recover_stuck_tasks_finalizes_open_task_runs_rows() {
3399 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3400 let mock = MockBackend::new();
3401 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3402 let task_manager = Arc::new(TaskManager::with_store(
3403 backend.clone(),
3404 store.clone(),
3405 "owner/repo".to_string(),
3406 ));
3407 let tmux = Arc::new(TmuxManager::new());
3408 let config = EngineConfig {
3409 no_session_stuck_timeout: 600,
3410 stuck_timeout: 1800,
3411 ..EngineConfig::default()
3412 };
3413
3414 let id = store
3415 .create_internal("owner/repo", "Stuck with open run", "", "cron", "1", None)
3416 .await
3417 .unwrap();
3418 store
3419 .set_fields(
3420 id,
3421 &[
3422 ("agent", serde_json::json!("claude")),
3423 ("model", serde_json::json!("sonnet")),
3424 ("route_attempts", serde_json::json!(1)),
3425 ],
3426 )
3427 .await
3428 .unwrap();
3429 store
3430 .update_status(id, crate::store::TaskStatus::InProgress)
3431 .await
3432 .unwrap();
3433 set_task_updated_at_past(&store, id).await;
3434
3435 store
3437 .start_run(&crate::store::StartRun {
3438 task_id: id,
3439 attempt: 1,
3440 run_type: "agent",
3441 agent: "claude",
3442 model: "sonnet",
3443 command: "claude --model sonnet",
3444 prompt: "fix it",
3445 })
3446 .await
3447 .unwrap();
3448
3449 tick_recover_stuck_tasks(
3450 &backend,
3451 &tmux,
3452 "owner/repo",
3453 &task_manager,
3454 &config,
3455 &store,
3456 &std::collections::HashMap::new(),
3457 &cooled_review_router_for_test(),
3458 &Arc::new(DashMap::new()),
3459 &Arc::new(DashSet::new()),
3460 )
3461 .await
3462 .unwrap();
3463
3464 let task = store.get(id).await.unwrap();
3465 assert_eq!(task.status, crate::store::TaskStatus::New);
3466
3467 let runs = store.get_runs(id).await.unwrap();
3468 assert_eq!(runs.len(), 1);
3469 assert_eq!(runs[0].outcome, "aborted");
3470 assert!(runs[0].completed_at.is_some());
3471 assert_eq!(
3472 runs[0].error,
3473 "stuck-task recovery: internal no session found"
3474 );
3475 }
3476
3477 #[tokio::test]
3494 async fn recover_stuck_tasks_skips_reclaim_while_dispatch_guard_held() {
3495 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3496 let mock = MockBackend::new();
3497 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3498 let task_manager = Arc::new(TaskManager::with_store(
3499 backend.clone(),
3500 store.clone(),
3501 "owner/repo".to_string(),
3502 ));
3503 let tmux = Arc::new(TmuxManager::new());
3504 let config = EngineConfig {
3505 no_session_stuck_timeout: 600,
3506 stuck_timeout: 1800,
3507 ..EngineConfig::default()
3508 };
3509
3510 let id = store
3511 .create_internal(
3512 "owner/repo",
3513 "Long-running daily job",
3514 "",
3515 "cron",
3516 "1",
3517 None,
3518 )
3519 .await
3520 .unwrap();
3521 store
3522 .set_fields(
3523 id,
3524 &[
3525 ("agent", serde_json::json!("claude")),
3526 ("model", serde_json::json!("sonnet")),
3527 ("route_attempts", serde_json::json!(1)),
3528 ],
3529 )
3530 .await
3531 .unwrap();
3532 store
3533 .update_status(id, crate::store::TaskStatus::InProgress)
3534 .await
3535 .unwrap();
3536 set_task_updated_at_past(&store, id).await;
3539
3540 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
3543 dispatching.insert(
3544 format!("owner/repo/internal:{id}"),
3545 format!("internal:{id}"),
3546 );
3547
3548 tick_recover_stuck_tasks(
3549 &backend,
3550 &tmux,
3551 "owner/repo",
3552 &task_manager,
3553 &config,
3554 &store,
3555 &std::collections::HashMap::new(),
3556 &cooled_review_router_for_test(),
3557 &dispatching,
3558 &Arc::new(DashSet::new()),
3559 )
3560 .await
3561 .unwrap();
3562
3563 let task = store.get(id).await.unwrap();
3564 assert_eq!(
3565 task.status,
3566 crate::store::TaskStatus::InProgress,
3567 "task should not be reclaimed while its dispatch guard is still held"
3568 );
3569
3570 let runs = store.get_runs(id).await.unwrap();
3572 assert!(runs.is_empty());
3573
3574 dispatching.clear();
3577 tick_recover_stuck_tasks(
3578 &backend,
3579 &tmux,
3580 "owner/repo",
3581 &task_manager,
3582 &config,
3583 &store,
3584 &std::collections::HashMap::new(),
3585 &cooled_review_router_for_test(),
3586 &dispatching,
3587 &Arc::new(DashSet::new()),
3588 )
3589 .await
3590 .unwrap();
3591 let task = store.get(id).await.unwrap();
3592 assert_eq!(
3593 task.status,
3594 crate::store::TaskStatus::New,
3595 "task should still be reclaimed once the dispatch guard is released"
3596 );
3597 }
3598
3599 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
3615 async fn recover_stuck_tasks_never_reclaims_while_guard_held_under_real_concurrency() {
3616 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3617 let mock = MockBackend::new();
3618 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3619 let task_manager = Arc::new(TaskManager::with_store(
3620 backend.clone(),
3621 store.clone(),
3622 "owner/repo".to_string(),
3623 ));
3624 let tmux = Arc::new(TmuxManager::new());
3625 let config = EngineConfig {
3626 no_session_stuck_timeout: 600,
3627 stuck_timeout: 1800,
3628 ..EngineConfig::default()
3629 };
3630
3631 let id = store
3632 .create_internal(
3633 "owner/repo",
3634 "Long-running daily job",
3635 "",
3636 "cron",
3637 "1",
3638 None,
3639 )
3640 .await
3641 .unwrap();
3642 store
3643 .set_fields(
3644 id,
3645 &[
3646 ("agent", serde_json::json!("claude")),
3647 ("model", serde_json::json!("sonnet")),
3648 ("route_attempts", serde_json::json!(1)),
3649 ],
3650 )
3651 .await
3652 .unwrap();
3653 store
3654 .update_status(id, crate::store::TaskStatus::InProgress)
3655 .await
3656 .unwrap();
3657 set_task_updated_at_past(&store, id).await;
3658
3659 let dispatch_key = format!("owner/repo/internal:{id}");
3660 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
3661
3662 let claim_task_id = format!("internal:{id}");
3669 {
3670 use dashmap::mapref::entry::Entry;
3671 match dispatching.entry(dispatch_key.clone()) {
3672 Entry::Vacant(slot) => {
3673 slot.insert(claim_task_id);
3674 }
3675 Entry::Occupied(_) => panic!("dispatch key already claimed"),
3676 }
3677 }
3678 let guard = DispatchGuard::new(Arc::clone(&dispatching), dispatch_key.clone());
3679
3680 let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
3691 let holder = tokio::spawn(async move {
3692 let _ = release_rx.await;
3693 drop(guard);
3694 });
3695
3696 const HAMMER_ITERATIONS: u32 = 300;
3704 for i in 0..HAMMER_ITERATIONS {
3705 tick_recover_stuck_tasks(
3706 &backend,
3707 &tmux,
3708 "owner/repo",
3709 &task_manager,
3710 &config,
3711 &store,
3712 &std::collections::HashMap::new(),
3713 &cooled_review_router_for_test(),
3714 &dispatching,
3715 &Arc::new(DashSet::new()),
3716 )
3717 .await
3718 .unwrap();
3719
3720 let task = store.get(id).await.unwrap();
3721 assert_eq!(
3722 task.status,
3723 crate::store::TaskStatus::InProgress,
3724 "task must never be reclaimed while the dispatch guard is deterministically \
3725 still held, even under real cross-thread concurrency (dispatch_key={dispatch_key}, \
3726 iteration={i}, contains_key_now={}, map_len_now={})",
3727 dispatching.contains_key(&dispatch_key),
3728 dispatching.len(),
3729 );
3730 tokio::task::yield_now().await;
3731 }
3732
3733 let _ = release_tx.send(());
3735 holder.await.unwrap();
3736
3737 tick_recover_stuck_tasks(
3739 &backend,
3740 &tmux,
3741 "owner/repo",
3742 &task_manager,
3743 &config,
3744 &store,
3745 &std::collections::HashMap::new(),
3746 &cooled_review_router_for_test(),
3747 &dispatching,
3748 &Arc::new(DashSet::new()),
3749 )
3750 .await
3751 .unwrap();
3752 let task = store.get(id).await.unwrap();
3753 assert_eq!(
3754 task.status,
3755 crate::store::TaskStatus::New,
3756 "task should be reclaimed once the dispatch guard is released"
3757 );
3758 }
3759
3760 #[tokio::test]
3761 async fn recover_stuck_tasks_skips_external_in_review_waiting_for_human_review() {
3762 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3763 let mock = MockBackend::new();
3764 let status_updates = mock.status_updates.clone();
3765 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3766 let task_manager = Arc::new(TaskManager::with_store(
3767 backend.clone(),
3768 store.clone(),
3769 "owner/repo".to_string(),
3770 ));
3771 let tmux = Arc::new(TmuxManager::new());
3772 let config = EngineConfig {
3773 no_session_stuck_timeout: 600,
3774 stuck_timeout: 1800,
3775 ..EngineConfig::default()
3776 };
3777
3778 let id = store
3779 .upsert_external(&crate::store::UpsertExternal {
3780 repo: "owner/repo",
3781 ext_id: "101",
3782 title: "Waiting For Human Review",
3783 body: "",
3784 author: "",
3785 url: "",
3786 labels: &[],
3787 origin: "github",
3788 })
3789 .await
3790 .unwrap();
3791 store
3792 .update_status(id, crate::store::TaskStatus::InReview)
3793 .await
3794 .unwrap();
3795 set_task_updated_at_past(&store, id).await;
3796
3797 tick_recover_stuck_tasks(
3798 &backend,
3799 &tmux,
3800 "owner/repo",
3801 &task_manager,
3802 &config,
3803 &store,
3804 &std::collections::HashMap::new(),
3805 &cooled_review_router_for_test(),
3806 &Arc::new(DashMap::new()),
3807 &Arc::new(DashSet::new()),
3808 )
3809 .await
3810 .unwrap();
3811
3812 let updates = status_updates.lock().unwrap();
3813 assert!(
3814 updates.is_empty(),
3815 "InReview task waiting for human review should not be reset"
3816 );
3817 }
3818
3819 #[tokio::test]
3820 async fn unblock_parents_no_blocked_tasks() {
3821 let mock = MockBackend::new(); let status_updates = mock.status_updates.clone();
3823 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3824 let task_manager = make_task_manager(backend.clone());
3825 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3826
3827 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3828 .await
3829 .unwrap();
3830
3831 assert!(status_updates.lock().unwrap().is_empty());
3832 }
3833
3834 #[tokio::test]
3835 async fn unblock_parents_handles_multiple_blocked_tasks() {
3836 let mut mock = MockBackend::new();
3837
3838 let t40 = make_task("40", &["status:blocked"]);
3840 mock.blocked_tasks.push(t40);
3841 mock.sub_issues
3842 .insert("40".to_string(), vec![ExternalId("41".to_string())]);
3843 mock.tasks_by_id
3844 .insert("41".to_string(), make_task("41", &["status:done"]));
3845
3846 let t50 = make_task("50", &["status:blocked"]);
3848 mock.blocked_tasks.push(t50);
3849 mock.sub_issues
3850 .insert("50".to_string(), vec![ExternalId("51".to_string())]);
3851 mock.tasks_by_id
3852 .insert("51".to_string(), make_task("51", &["status:in_progress"]));
3853
3854 let status_updates = mock.status_updates.clone();
3855 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3856 let task_manager = make_task_manager(backend.clone());
3857 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3858
3859 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3860 .await
3861 .unwrap();
3862
3863 let updates = status_updates.lock().unwrap();
3864 assert_eq!(updates.len(), 1, "only one parent should be unblocked");
3865 assert_eq!(updates[0].0, "40", "task 40 should be unblocked");
3866 assert_eq!(updates[0].1, Status::New);
3867 }
3868
3869 #[tokio::test]
3870 async fn detect_silent_agents_skips_backend_for_internal_tasks() {
3871 let transport = Arc::new(Transport::new());
3872 let capture = Arc::new(CaptureService::new(transport));
3873 let mock = Arc::new(MockBackend::new());
3874 let backend: Arc<dyn ExternalBackend> = mock.clone();
3875 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3876 let task_manager = Arc::new(TaskManager::with_store(
3877 backend.clone(),
3878 store.clone(),
3879 "owner/repo".to_string(),
3880 ));
3881 let tmux = Arc::new(TmuxManager::new());
3882 let config = EngineConfig {
3883 silence_grace_period: 0,
3884 ..EngineConfig::default()
3885 };
3886
3887 let internal_id = store
3888 .create_internal("owner/repo", "Silent internal", "", "cron", "1", None)
3889 .await
3890 .unwrap();
3891 store
3892 .update_status(internal_id, crate::store::TaskStatus::InProgress)
3893 .await
3894 .unwrap();
3895
3896 let task_id = format!("internal:{internal_id}");
3897 capture
3898 .register_session("owner/repo", &task_id, "orch-test-internal")
3899 .await;
3900 tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
3901
3902 tick_detect_silent_agents(
3903 &tmux,
3904 "owner/repo",
3905 &capture,
3906 &backend,
3907 &task_manager,
3908 &config,
3909 &store,
3910 )
3911 .await
3912 .unwrap();
3913
3914 assert!(
3915 mock.removed_labels.lock().unwrap().is_empty(),
3916 "internal tasks should skip backend label removal"
3917 );
3918 assert!(
3919 mock.posted_comments.lock().unwrap().is_empty(),
3920 "internal tasks should skip backend comments"
3921 );
3922
3923 let task = store.get(internal_id).await.unwrap();
3924 assert!(
3928 matches!(
3929 task.status,
3930 crate::store::TaskStatus::Routed | crate::store::TaskStatus::New
3931 ),
3932 "internal task should be routed to fallback or reset to new, got {:?}",
3933 task.status
3934 );
3935 }
3936
3937 #[tokio::test]
3938 async fn detect_silent_agents_finalizes_open_task_runs_rows() {
3939 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3940 let mock = MockBackend::new();
3941 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3942 let task_manager = Arc::new(TaskManager::with_store(
3943 backend.clone(),
3944 store.clone(),
3945 "owner/repo".to_string(),
3946 ));
3947 let transport = Arc::new(Transport::new());
3948 let capture = Arc::new(CaptureService::new(transport));
3949 let tmux = Arc::new(TmuxManager::new());
3950 let config = EngineConfig {
3951 silence_grace_period: 60,
3952 silence_cooldown: 300,
3953 ..EngineConfig::default()
3954 };
3955
3956 let internal_id = store
3957 .create_internal("owner/repo", "Silent with open run", "", "cron", "1", None)
3958 .await
3959 .unwrap();
3960 store
3961 .set_fields(
3962 internal_id,
3963 &[
3964 ("agent", serde_json::json!("claude")),
3965 ("model", serde_json::json!("sonnet")),
3966 ],
3967 )
3968 .await
3969 .unwrap();
3970 store
3971 .update_status(internal_id, crate::store::TaskStatus::InProgress)
3972 .await
3973 .unwrap();
3974
3975 let task_id = format!("internal:{internal_id}");
3976 capture
3977 .register_session("owner/repo", &task_id, "orch-test-silent-run")
3978 .await;
3979 capture
3980 .set_buffer_state_for_test(
3981 "owner/repo",
3982 &task_id,
3983 false,
3984 false,
3985 chrono::Utc::now() - chrono::Duration::seconds(120),
3986 )
3987 .await;
3988
3989 store
3991 .start_run(&crate::store::StartRun {
3992 task_id: internal_id,
3993 attempt: 1,
3994 run_type: "agent",
3995 agent: "claude",
3996 model: "sonnet",
3997 command: "claude --model sonnet",
3998 prompt: "fix it",
3999 })
4000 .await
4001 .unwrap();
4002
4003 tick_detect_silent_agents(
4004 &tmux,
4005 "owner/repo",
4006 &capture,
4007 &backend,
4008 &task_manager,
4009 &config,
4010 &store,
4011 )
4012 .await
4013 .unwrap();
4014
4015 let runs = store.get_runs(internal_id).await.unwrap();
4016 assert_eq!(runs.len(), 1);
4017 assert_eq!(runs[0].outcome, "aborted");
4018 assert!(runs[0].completed_at.is_some());
4019 assert_eq!(runs[0].error, "silence detection: session killed");
4020 }
4021
4022 #[tokio::test]
4033 async fn phase1_session_completion_touch_prevents_phase2_reclaim() {
4034 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4035 let mock = MockBackend::new();
4036 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4037 let task_manager = Arc::new(TaskManager::with_store(
4038 backend.clone(),
4039 store.clone(),
4040 "owner/repo".to_string(),
4041 ));
4042 let tmux = Arc::new(TmuxManager::new());
4043 let config = EngineConfig {
4044 no_session_stuck_timeout: 600,
4045 stuck_timeout: 1800,
4046 ..EngineConfig::default()
4047 };
4048
4049 let id = store
4053 .create_internal("owner/repo", "Completed task", "", "cron", "1", None)
4054 .await
4055 .unwrap();
4056 store
4057 .update_status(id, crate::store::TaskStatus::InProgress)
4058 .await
4059 .unwrap();
4060 set_task_updated_at_past(&store, id).await;
4061
4062 let task_id = format!("internal:{id}");
4065 store_touch_updated_at(&Some(Arc::clone(&store)), "owner/repo", &task_id).await;
4066
4067 tick_recover_stuck_tasks(
4069 &backend,
4070 &tmux,
4071 "owner/repo",
4072 &task_manager,
4073 &config,
4074 &store,
4075 &std::collections::HashMap::new(),
4076 &cooled_review_router_for_test(),
4077 &Arc::new(DashMap::new()),
4078 &Arc::new(DashSet::new()),
4079 )
4080 .await
4081 .unwrap();
4082
4083 let task = store.get(id).await.unwrap();
4084 assert_eq!(
4085 task.status,
4086 crate::store::TaskStatus::InProgress,
4087 "Phase 2 must not reclaim a task whose updated_at was just touched by Phase 1"
4088 );
4089 }
4090
4091 #[tokio::test]
4092 async fn stuck_task_timing_treats_dead_session_as_missing() {
4093 let tmux = Arc::new(TmuxManager::new());
4094 let repo = "owner/repo";
4095 let task_id = "internal:33498";
4096 let session_name = tmux.session_name(repo, task_id);
4097
4098 let create_result = tokio::process::Command::new("tmux")
4099 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
4100 .output()
4101 .await;
4102
4103 match create_result {
4104 Ok(o) if o.status.success() => {}
4105 _ => {
4106 eprintln!("Skipping test: tmux not available or failed to create test session");
4107 return;
4108 }
4109 }
4110
4111 let _guard = SessionGuard(session_name.clone());
4112
4113 let set_option_result = tokio::process::Command::new("tmux")
4114 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
4115 .output()
4116 .await;
4117 if !matches!(set_option_result, Ok(ref o) if o.status.success()) {
4118 eprintln!("Skipping test: unable to set tmux remain-on-exit option");
4119 return;
4120 }
4121
4122 let send_exit_result = tokio::process::Command::new("tmux")
4123 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
4124 .output()
4125 .await;
4126 if !matches!(send_exit_result, Ok(ref o) if o.status.success()) {
4127 eprintln!("Skipping test: unable to exit tmux pane");
4128 return;
4129 }
4130
4131 for _ in 0..20 {
4133 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4134 if !tmux.session_is_running(&session_name).await {
4135 break;
4136 }
4137 }
4138
4139 let mut session_map = tmux.batch_session_active().await;
4144 for _ in 0..20 {
4145 if !session_map.get(&session_name).copied().unwrap_or(false) {
4146 break;
4147 }
4148 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4149 session_map = tmux.batch_session_active().await;
4150 }
4151 if session_map.get(&session_name).copied().unwrap_or(false) {
4152 eprintln!("Skipping test: session still alive in batch_session_active after retries (CI tmux lag)");
4153 return;
4154 }
4155
4156 let config = EngineConfig {
4157 no_session_stuck_timeout: 600,
4158 stuck_timeout: 1800,
4159 ..EngineConfig::default()
4160 };
4161 let updated_at = (chrono::Utc::now()
4162 - chrono::Duration::seconds(config.no_session_stuck_timeout as i64 + 5))
4163 .to_rfc3339();
4164
4165 let timing = stuck_task_timing_from_map(
4166 &tmux,
4167 repo,
4168 task_id,
4169 &updated_at,
4170 &config,
4171 "parse failure",
4172 &session_map,
4173 )
4174 .expect("dead session should be treated as missing");
4175
4176 assert!(!timing.has_session);
4177 assert_eq!(timing.threshold, config.no_session_stuck_timeout);
4178 }
4179
4180 #[tokio::test]
4181 #[serial_test::serial(suspend_state)]
4182 async fn stuck_task_timing_discounts_host_suspend_gap() {
4183 super::super::suspend::clear_for_test();
4184
4185 let tmux = Arc::new(TmuxManager::new());
4186 let repo = "owner/repo";
4187 let task_id = "internal:99001";
4188 let session_map = std::collections::HashMap::new();
4189
4190 let config = EngineConfig {
4191 no_session_stuck_timeout: 1800,
4192 stuck_timeout: 1800,
4193 ..EngineConfig::default()
4194 };
4195
4196 let updated_at = (chrono::Utc::now() - chrono::Duration::minutes(33)).to_rfc3339();
4200 let suspend_gap_detected_at = chrono::Utc::now() - chrono::Duration::minutes(1);
4201 super::super::suspend::inject_gap_for_test(
4202 suspend_gap_detected_at,
4203 chrono::Duration::minutes(30),
4204 );
4205
4206 let timing = stuck_task_timing_from_map(
4207 &tmux,
4208 repo,
4209 task_id,
4210 &updated_at,
4211 &config,
4212 "parse failure",
4213 &session_map,
4214 );
4215
4216 assert!(
4217 timing.is_none(),
4218 "task age should be discounted below the stuck threshold once suspend time is subtracted, got {timing:?}"
4219 );
4220
4221 super::super::suspend::clear_for_test();
4222 }
4223
4224 #[tokio::test]
4225 async fn recover_stuck_tasks_reclaims_routed_task_blocked_by_stale_session() {
4226 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4227 let mock = MockBackend::new();
4228 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4229 let task_manager = Arc::new(TaskManager::with_store(
4230 backend.clone(),
4231 store.clone(),
4232 "owner/repo".to_string(),
4233 ));
4234 let tmux = Arc::new(TmuxManager::new());
4235 let config = EngineConfig {
4236 no_session_stuck_timeout: 600,
4237 stuck_timeout: 1,
4238 ..EngineConfig::default()
4239 };
4240
4241 let id = store
4242 .create_internal("owner/repo", "Blocked routed task", "", "cron", "1", None)
4243 .await
4244 .unwrap();
4245 store
4246 .update_status(id, crate::store::TaskStatus::Routed)
4247 .await
4248 .unwrap();
4249 set_task_updated_at_past(&store, id).await;
4250
4251 let task_id = format!("internal:{id}");
4252 let session_name = tmux.session_name("owner/repo", &task_id);
4253 let create_result = tokio::process::Command::new("tmux")
4254 .args([
4255 "new-session",
4256 "-d",
4257 "-s",
4258 &session_name,
4259 "-c",
4260 "/tmp",
4261 "sleep 60",
4262 ])
4263 .output()
4264 .await;
4265
4266 match create_result {
4267 Ok(o) if o.status.success() => {}
4268 _ => {
4269 eprintln!("Skipping test: tmux not available or failed to create test session");
4270 return;
4271 }
4272 }
4273
4274 let session_map = tmux.batch_session_active().await;
4275 assert_eq!(session_map.get(&session_name).copied(), Some(true));
4276
4277 tick_recover_stuck_tasks(
4278 &backend,
4279 &tmux,
4280 "owner/repo",
4281 &task_manager,
4282 &config,
4283 &store,
4284 &session_map,
4285 &cooled_review_router_for_test(),
4286 &Arc::new(DashMap::new()),
4287 &Arc::new(DashSet::new()),
4288 )
4289 .await
4290 .unwrap();
4291
4292 let task = store.get(id).await.unwrap();
4293 assert_eq!(
4294 task.status,
4295 crate::store::TaskStatus::New,
4296 "routed task blocked by a stale session should be reset for re-routing"
4297 );
4298 assert!(
4299 !tmux.session_exists(&session_name).await,
4300 "recovery should kill the stale tmux session so dispatch can proceed"
4301 );
4302 }
4303
4304 #[tokio::test]
4318 async fn dispatch_does_not_deadlock_under_write_lock() {
4319 use crate::engine::router::Router;
4320 use tokio::sync::RwLock;
4321
4322 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4323 let mock = MockBackend::new();
4324 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4325 let task_manager = Arc::new(TaskManager::with_store(
4326 backend.clone(),
4327 store.clone(),
4328 "owner/repo".to_string(),
4329 ));
4330 let tmux = Arc::new(TmuxManager::new());
4331 let semaphore = Arc::new(Semaphore::new(4));
4332 let (weight_tx, _weight_rx) = mpsc::channel(16);
4333 let dispatching = Arc::new(DashMap::new());
4334 let transport = Arc::new(Transport::new());
4335 let capture = Arc::new(CaptureService::new(transport));
4336
4337 let router_config = crate::engine::router::RouterConfig::default();
4338 let router = Router::new(router_config);
4339 let router_arc = Arc::new(RwLock::new(router));
4340
4341 let write_guard = router_arc.write().await;
4345 let dispatch_mode = dispatch_mode_from_router(&write_guard);
4346
4347 let result = tokio::time::timeout(
4348 std::time::Duration::from_secs(2),
4349 tick_dispatch_tasks(
4350 &backend,
4351 &tmux,
4352 "owner/repo",
4353 &Arc::new(crate::engine::runner::TaskRunner::new(
4354 "owner/repo".to_string(),
4355 )),
4356 &capture,
4357 &semaphore,
4358 &task_manager,
4359 &weight_tx,
4360 dispatch_mode,
4361 &dispatching,
4362 &store,
4363 &std::collections::HashMap::new(),
4364 ),
4365 )
4366 .await;
4367
4368 assert!(
4369 result.is_ok(),
4370 "tick_dispatch_tasks deadlocked! It tried to acquire a read lock \
4371 on router_arc while a write lock was already held (issue #1361)"
4372 );
4373 }
4374
4375 #[tokio::test]
4383 async fn dispatch_skips_task_whose_routed_model_entered_cooldown() {
4384 use crate::store::{StoreRoute, TaskStatus as DbStatus, UpsertExternal};
4385
4386 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4387 let mock = MockBackend::new();
4388 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4389 let task_manager = Arc::new(TaskManager::with_store(
4390 backend.clone(),
4391 store.clone(),
4392 "owner/repo".to_string(),
4393 ));
4394 let tmux = Arc::new(TmuxManager::new());
4395 let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
4396 let semaphore = Arc::new(Semaphore::new(4));
4397 let (weight_tx, _weight_rx) = mpsc::channel(16);
4398 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4399 let transport = Arc::new(Transport::new());
4400 let capture = Arc::new(CaptureService::new(transport));
4401
4402 let id = store
4403 .upsert_external(&UpsertExternal {
4404 repo: "owner/repo",
4405 ext_id: "999",
4406 title: "Stale routed task",
4407 body: "",
4408 author: "user",
4409 url: "https://github.com/owner/repo/issues/999",
4410 labels: &[],
4411 origin: "github",
4412 })
4413 .await
4414 .unwrap();
4415 store
4416 .store_route(&StoreRoute {
4417 id,
4418 agent: "minimax",
4419 model: Some("opus"),
4420 complexity: "medium",
4421 estimate: 0,
4422 reason: "test",
4423 profile: "{}",
4424 skills: "[]",
4425 })
4426 .await
4427 .unwrap();
4428 store.update_status(id, DbStatus::Routed).await.unwrap();
4429
4430 crate::engine::cooldown::set_model_cooldown(
4433 "minimax",
4434 "opus",
4435 300,
4436 "rate_limit_usage_window",
4437 )
4438 .await;
4439
4440 let dispatch_mode = DispatchMode {
4441 is_degraded: false,
4442 healthy_agents: 1,
4443 threshold: 1,
4444 };
4445
4446 tick_dispatch_tasks(
4447 &backend,
4448 &tmux,
4449 "owner/repo",
4450 &runner,
4451 &capture,
4452 &semaphore,
4453 &task_manager,
4454 &weight_tx,
4455 dispatch_mode,
4456 &dispatching,
4457 &store,
4458 &std::collections::HashMap::new(),
4459 )
4460 .await
4461 .unwrap();
4462
4463 let task = store.get(id).await.unwrap();
4464 assert_eq!(
4465 task.status,
4466 DbStatus::Routed,
4467 "task whose routed model is now cooled down must stay Routed, not be dispatched"
4468 );
4469 assert!(
4470 dispatching.is_empty(),
4471 "dispatch guard must be released when skipping a stale-cooldown task"
4472 );
4473 }
4474
4475 #[tokio::test]
4488 async fn completed_session_does_not_cause_stuck_reclaim_race() {
4489 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4490 let mock = MockBackend::new();
4491 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4492 let task_manager = Arc::new(TaskManager::with_store(
4493 backend.clone(),
4494 store.clone(),
4495 "owner/repo".to_string(),
4496 ));
4497 let tmux = Arc::new(TmuxManager::new());
4498 let transport = Arc::new(Transport::new());
4499 let capture = Arc::new(CaptureService::new(transport));
4500
4501 let id = store
4503 .upsert_external(&crate::store::UpsertExternal {
4504 repo: "owner/repo",
4505 ext_id: "200",
4506 title: "Race Task",
4507 body: "",
4508 author: "",
4509 url: "",
4510 labels: &[],
4511 origin: "github",
4512 })
4513 .await
4514 .unwrap();
4515 store
4516 .update_status(id, crate::store::TaskStatus::InProgress)
4517 .await
4518 .unwrap();
4519
4520 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4522 .bind(id)
4523 .execute(store.pool())
4524 .await
4525 .unwrap();
4526
4527 let task_id = "200";
4529 let session_name = tmux.session_name("owner/repo", task_id);
4530 let create_result = tokio::process::Command::new("tmux")
4531 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
4532 .output()
4533 .await;
4534 match create_result {
4535 Ok(o) if o.status.success() => {}
4536 _ => {
4537 eprintln!("Skipping test: tmux not available or failed to create test session");
4538 return;
4539 }
4540 }
4541 let _guard = SessionGuard(session_name.clone());
4542
4543 let _ = tokio::process::Command::new("tmux")
4545 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
4546 .output()
4547 .await;
4548 let _ = tokio::process::Command::new("tmux")
4549 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
4550 .output()
4551 .await;
4552
4553 for _ in 0..20 {
4555 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4556 if !tmux.session_is_running(&session_name).await {
4557 break;
4558 }
4559 }
4560
4561 let task_key = task_id.to_string();
4563 capture
4564 .register_session("owner/repo", &task_key, &session_name)
4565 .await;
4566
4567 let config = EngineConfig {
4569 no_session_stuck_timeout: 600,
4570 stuck_timeout: 1800,
4571 ..EngineConfig::default()
4572 };
4573
4574 tick_check_session_completions(&tmux, "owner/repo", &capture, &store)
4575 .await
4576 .unwrap();
4577
4578 {
4584 let task_state = store.get(id).await.unwrap();
4585 if task_state.updated_at.starts_with("2020") {
4586 eprintln!(
4587 "Skipping test: tick_check_session_completions did not update stored \
4588 updated_at — session was not in snapshot (CI tmux environment issue)"
4589 );
4590 return;
4591 }
4592 }
4593
4594 tick_recover_stuck_tasks(
4596 &backend,
4597 &tmux,
4598 "owner/repo",
4599 &task_manager,
4600 &config,
4601 &store,
4602 &std::collections::HashMap::new(),
4603 &cooled_review_router_for_test(),
4604 &Arc::new(DashMap::new()),
4605 &Arc::new(DashSet::new()),
4606 )
4607 .await
4608 .unwrap();
4609
4610 let task = store.get(id).await.unwrap();
4612 assert_eq!(
4613 task.status,
4614 crate::store::TaskStatus::InProgress,
4615 "task should not be reclaimed to New"
4616 );
4617 }
4618
4619 #[tokio::test]
4629 async fn session_completion_matches_repo_configured_with_git_suffix() {
4630 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4631 let tmux = Arc::new(TmuxManager::new());
4632 let transport = Arc::new(Transport::new());
4633 let capture = Arc::new(CaptureService::new(transport));
4634
4635 let repo = "owner/repo.git";
4636 let id = store
4637 .upsert_external(&crate::store::UpsertExternal {
4638 repo,
4639 ext_id: "201",
4640 title: "Git-suffix repo task",
4641 body: "",
4642 author: "",
4643 url: "",
4644 labels: &[],
4645 origin: "github",
4646 })
4647 .await
4648 .unwrap();
4649 store
4650 .update_status(id, crate::store::TaskStatus::InProgress)
4651 .await
4652 .unwrap();
4653 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4654 .bind(id)
4655 .execute(store.pool())
4656 .await
4657 .unwrap();
4658
4659 let task_id = "201";
4662 let session_name = tmux.session_name(repo, task_id);
4663 let create_result = tokio::process::Command::new("tmux")
4664 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
4665 .output()
4666 .await;
4667 match create_result {
4668 Ok(o) if o.status.success() => {}
4669 _ => {
4670 eprintln!("Skipping test: tmux not available or failed to create test session");
4671 return;
4672 }
4673 }
4674 let _guard = SessionGuard(session_name.clone());
4675
4676 let _ = tokio::process::Command::new("tmux")
4677 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
4678 .output()
4679 .await;
4680 let _ = tokio::process::Command::new("tmux")
4681 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
4682 .output()
4683 .await;
4684
4685 for _ in 0..20 {
4686 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4687 if !tmux.session_is_running(&session_name).await {
4688 break;
4689 }
4690 }
4691
4692 capture.register_session(repo, task_id, &session_name).await;
4693
4694 tick_check_session_completions(&tmux, repo, &capture, &store)
4695 .await
4696 .unwrap();
4697
4698 let task_state = store.get(id).await.unwrap();
4699 if task_state.updated_at.starts_with("2020") {
4700 eprintln!(
4701 "Skipping test: tick_check_session_completions did not update stored \
4702 updated_at — session was not in snapshot (CI tmux environment issue)"
4703 );
4704 return;
4705 }
4706 assert!(
4707 !task_state.updated_at.starts_with("2020"),
4708 "tick_check_session_completions must match its own repo's live sessions \
4709 even when the repo is configured with a \".git\" suffix"
4710 );
4711 }
4712
4713 #[tokio::test]
4719 async fn job_scheduler_returns_ok_on_duplicate_job_config() {
4720 let tmp = tempfile::tempdir().unwrap();
4721 let cfg = tmp.path().join(".orch.yml");
4722 std::fs::write(
4723 &cfg,
4724 "jobs:\n - id: trading-scan\n schedule: \"0 9 * * *\"\n task:\n title: Scan\n body: do it\n",
4725 )
4726 .unwrap();
4727 let jobs_dir = tmp.path().join("prompts").join("jobs");
4728 std::fs::create_dir_all(&jobs_dir).unwrap();
4729 std::fs::write(
4730 jobs_dir.join("trading-scan.md"),
4731 "---\nid: trading-scan\nschedule: '0 9 * * *'\ntitle: Trading Scan\n---\n\nDo trading scan.\n",
4732 )
4733 .unwrap();
4734
4735 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4736 let result = tick_job_scheduler(&cfg, &backend, None, "test/repo", None).await;
4737 assert!(
4738 result.is_ok(),
4739 "duplicate job id must not propagate as Err: {result:?}"
4740 );
4741 }
4742
4743 #[tokio::test]
4747 async fn job_scheduler_processes_valid_project_when_other_has_duplicate() {
4748 let bad_tmp = tempfile::tempdir().unwrap();
4750 let bad_cfg = bad_tmp.path().join(".orch.yml");
4751 std::fs::write(
4752 &bad_cfg,
4753 "jobs:\n - id: dup\n schedule: \"0 9 * * *\"\n task:\n title: Dup\n body: x\n",
4754 )
4755 .unwrap();
4756 let bad_jobs_dir = bad_tmp.path().join("prompts").join("jobs");
4757 std::fs::create_dir_all(&bad_jobs_dir).unwrap();
4758 std::fs::write(
4759 bad_jobs_dir.join("dup.md"),
4760 "---\nid: dup\nschedule: '0 9 * * *'\ntitle: Dup\n---\n\nbody\n",
4761 )
4762 .unwrap();
4763
4764 let good_tmp = tempfile::tempdir().unwrap();
4766 let good_cfg = good_tmp.path().join(".orch.yml");
4767 std::fs::write(
4768 &good_cfg,
4769 "jobs:\n - id: valid-job\n schedule: \"0 9 * * *\"\n task:\n title: Valid\n body: ok\n",
4770 )
4771 .unwrap();
4772
4773 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4774
4775 let bad_result = tick_job_scheduler(&bad_cfg, &backend, None, "test/bad-repo", None).await;
4776 assert!(
4777 bad_result.is_ok(),
4778 "bad project must not abort scheduler: {bad_result:?}"
4779 );
4780
4781 let good_result =
4782 tick_job_scheduler(&good_cfg, &backend, None, "test/good-repo", None).await;
4783 assert!(
4784 good_result.is_ok(),
4785 "valid project must still run after bad project: {good_result:?}"
4786 );
4787 }
4788
4789 #[serial_test::serial(cooldown_state)]
4797 #[tokio::test]
4798 async fn global_sweep_routes_inactive_repo_tasks() {
4799 use crate::engine::router::{Router, RouterConfig};
4800 use crate::store::{TaskStatus, UpsertExternal};
4801
4802 crate::engine::cooldown::reset_global_state().await;
4803
4804 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4805
4806 let active_id = store
4808 .upsert_external(&UpsertExternal {
4809 repo: "active/repo",
4810 ext_id: "1",
4811 title: "Active task",
4812 body: "",
4813 author: "user",
4814 url: "https://github.com/active/repo/issues/1",
4815 labels: &[],
4816 origin: "github",
4817 })
4818 .await
4819 .unwrap();
4820
4821 let inactive_id = store
4823 .upsert_external(&UpsertExternal {
4824 repo: "inactive/repo",
4825 ext_id: "42",
4826 title: "Orphaned task",
4827 body: "",
4828 author: "user",
4829 url: "https://github.com/inactive/repo/issues/42",
4830 labels: &[],
4831 origin: "github",
4832 })
4833 .await
4834 .unwrap();
4835
4836 assert_eq!(store.get(active_id).await.unwrap().status, TaskStatus::New);
4837 assert_eq!(
4838 store.get(inactive_id).await.unwrap().status,
4839 TaskStatus::New
4840 );
4841
4842 let mut model_map: std::collections::HashMap<
4843 String,
4844 std::collections::HashMap<String, Vec<String>>,
4845 > = std::collections::HashMap::new();
4846 let mut tier = std::collections::HashMap::new();
4847 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
4848 model_map.insert("medium".to_string(), tier);
4849 let config = RouterConfig {
4850 mode: "round_robin".to_string(),
4851 agents: vec!["claude".to_string()],
4852 model_map,
4853 ..Default::default()
4854 };
4855 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
4856
4857 let active_repos: std::collections::HashSet<String> =
4858 ["active/repo".to_string()].into_iter().collect();
4859 let mut routing_quota = RoutingTickQuota::new(1);
4860 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
4861 .await
4862 .unwrap();
4863
4864 assert_eq!(
4866 store.get(active_id).await.unwrap().status,
4867 TaskStatus::New,
4868 "global sweep must not touch tasks for the active repo"
4869 );
4870
4871 let after = store.get(inactive_id).await.unwrap();
4873 assert_eq!(
4874 after.status,
4875 TaskStatus::Routed,
4876 "global sweep must route tasks from inactive repos"
4877 );
4878 assert!(
4879 matches!(&after.agent, Some(a) if !a.is_empty()),
4880 "agent must be set after routing"
4881 );
4882 }
4883
4884 #[serial_test::serial(cooldown_state)]
4892 #[tokio::test]
4893 async fn global_sweep_does_not_touch_other_active_repo_tasks() {
4894 use crate::engine::router::{Router, RouterConfig};
4895 use crate::store::{TaskStatus, UpsertExternal};
4896
4897 crate::engine::cooldown::reset_global_state().await;
4898
4899 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4900
4901 let other_active_id = store
4904 .upsert_external(&UpsertExternal {
4905 repo: "other-active/repo",
4906 ext_id: "7",
4907 title: "Other active project's task",
4908 body: "",
4909 author: "user",
4910 url: "https://github.com/other-active/repo/issues/7",
4911 labels: &[],
4912 origin: "github",
4913 })
4914 .await
4915 .unwrap();
4916
4917 let inactive_id = store
4919 .upsert_external(&UpsertExternal {
4920 repo: "inactive/repo",
4921 ext_id: "42",
4922 title: "Orphaned task",
4923 body: "",
4924 author: "user",
4925 url: "https://github.com/inactive/repo/issues/42",
4926 labels: &[],
4927 origin: "github",
4928 })
4929 .await
4930 .unwrap();
4931
4932 let mut model_map: std::collections::HashMap<
4933 String,
4934 std::collections::HashMap<String, Vec<String>>,
4935 > = std::collections::HashMap::new();
4936 let mut tier = std::collections::HashMap::new();
4937 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
4938 model_map.insert("medium".to_string(), tier);
4939 let config = RouterConfig {
4940 mode: "round_robin".to_string(),
4941 agents: vec!["claude".to_string()],
4942 model_map,
4943 ..Default::default()
4944 };
4945 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
4946
4947 let active_repos: std::collections::HashSet<String> =
4951 ["active/repo".to_string(), "other-active/repo".to_string()]
4952 .into_iter()
4953 .collect();
4954 let mut routing_quota = RoutingTickQuota::new(1);
4955 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
4956 .await
4957 .unwrap();
4958
4959 assert_eq!(
4960 store.get(other_active_id).await.unwrap().status,
4961 TaskStatus::New,
4962 "global sweep run from a different active repo's tick must not route \
4963 another active repo's new tasks — that repo's own tick will route them"
4964 );
4965 assert_eq!(
4966 store.get(inactive_id).await.unwrap().status,
4967 TaskStatus::Routed,
4968 "global sweep must still route tasks from genuinely orphaned repos"
4969 );
4970 }
4971
4972 #[serial_test::serial(cooldown_state)]
4974 #[tokio::test]
4975 async fn global_sweep_skips_no_agent_tasks() {
4976 use crate::engine::router::{Router, RouterConfig};
4977 use crate::store::{TaskStatus, UpsertExternal};
4978
4979 crate::engine::cooldown::reset_global_state().await;
4980
4981 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4982
4983 let id = store
4984 .upsert_external(&UpsertExternal {
4985 repo: "inactive/repo",
4986 ext_id: "99",
4987 title: "No-agent task",
4988 body: "",
4989 author: "user",
4990 url: "https://github.com/inactive/repo/issues/99",
4991 labels: &["no-agent".to_string()],
4992 origin: "github",
4993 })
4994 .await
4995 .unwrap();
4996
4997 let mut model_map: std::collections::HashMap<
4998 String,
4999 std::collections::HashMap<String, Vec<String>>,
5000 > = std::collections::HashMap::new();
5001 let mut tier = std::collections::HashMap::new();
5002 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5003 model_map.insert("medium".to_string(), tier);
5004 let config = RouterConfig {
5005 mode: "round_robin".to_string(),
5006 agents: vec!["claude".to_string()],
5007 model_map,
5008 ..Default::default()
5009 };
5010 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5011
5012 let active_repos: std::collections::HashSet<String> =
5013 ["active/repo".to_string()].into_iter().collect();
5014 let mut routing_quota = RoutingTickQuota::new(1);
5015 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5016 .await
5017 .unwrap();
5018
5019 assert_eq!(
5020 store.get(id).await.unwrap().status,
5021 TaskStatus::New,
5022 "no-agent tasks must not be routed"
5023 );
5024 }
5025
5026 #[serial_test::serial(cooldown_state)]
5031 #[tokio::test]
5032 async fn global_sweep_honors_shared_routing_quota() {
5033 use crate::engine::router::{Router, RouterConfig};
5034 use crate::store::{TaskStatus, UpsertExternal};
5035
5036 crate::engine::cooldown::reset_global_state().await;
5037
5038 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5039 for ext_id in ["1", "2"] {
5040 store
5041 .upsert_external(&UpsertExternal {
5042 repo: "inactive/repo",
5043 ext_id,
5044 title: "Orphaned task",
5045 body: "",
5046 author: "user",
5047 url: &format!("https://github.com/inactive/repo/issues/{ext_id}"),
5048 labels: &[],
5049 origin: "github",
5050 })
5051 .await
5052 .unwrap();
5053 }
5054
5055 let mut model_map: std::collections::HashMap<
5056 String,
5057 std::collections::HashMap<String, Vec<String>>,
5058 > = std::collections::HashMap::new();
5059 let mut tier = std::collections::HashMap::new();
5060 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5061 model_map.insert("medium".to_string(), tier);
5062 let config = RouterConfig {
5063 mode: "round_robin".to_string(),
5064 agents: vec!["claude".to_string()],
5065 model_map,
5066 ..Default::default()
5067 };
5068 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5069
5070 let active_repos: std::collections::HashSet<String> =
5071 ["active/repo".to_string()].into_iter().collect();
5072 let mut routing_quota = RoutingTickQuota::new(1);
5073 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5074 .await
5075 .unwrap();
5076
5077 let all_tasks = store.list_all_global().await.unwrap();
5078 let routed = all_tasks
5079 .iter()
5080 .filter(|task| task.status == TaskStatus::Routed)
5081 .count();
5082 let still_new = all_tasks
5083 .iter()
5084 .filter(|task| task.status == TaskStatus::New)
5085 .count();
5086
5087 assert_eq!(routed, 1, "only one task may consume the shared quota");
5088 assert_eq!(still_new, 1, "remaining tasks wait for the next tick");
5089
5090 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5091 .await
5092 .unwrap();
5093 let after_exhausted = store.list_all_global().await.unwrap();
5094 assert_eq!(
5095 after_exhausted
5096 .iter()
5097 .filter(|task| task.status == TaskStatus::Routed)
5098 .count(),
5099 1,
5100 "an exhausted shared quota must not reset within the same tick"
5101 );
5102 }
5103
5104 #[tokio::test]
5121 async fn group_orphaned_routed_tasks_selects_inactive_repo_tasks() {
5122 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
5123
5124 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5125
5126 async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
5127 let id = store
5128 .upsert_external(&UpsertExternal {
5129 repo,
5130 ext_id,
5131 title: "Routed task",
5132 body: "",
5133 author: "user",
5134 url: &format!("https://github.com/{repo}/issues/{ext_id}"),
5135 labels: &[],
5136 origin: "github",
5137 })
5138 .await
5139 .unwrap();
5140 store
5141 .store_route(&StoreRoute {
5142 id,
5143 agent: "claude",
5144 model: Some("sonnet"),
5145 complexity: "medium",
5146 estimate: 0,
5147 reason: "test",
5148 profile: "{}",
5149 skills: "[]",
5150 })
5151 .await
5152 .unwrap();
5153 store.update_status(id, TaskStatus::Routed).await.unwrap();
5154 id
5155 }
5156
5157 let active_id = make_routed(&store, "active/repo", "1").await;
5158 let inactive_id_a = make_routed(&store, "inactive/repo", "2").await;
5159 let other_inactive_id = make_routed(&store, "other-inactive/repo", "3").await;
5160 let inactive_id_b = make_routed(&store, "inactive/repo", "4").await;
5161
5162 let all_routed = store
5163 .list_all_by_status_global(TaskStatus::Routed)
5164 .await
5165 .unwrap();
5166 let active_repos: std::collections::HashSet<String> =
5167 ["active/repo".to_string()].into_iter().collect();
5168 let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
5169
5170 assert!(
5171 !by_repo.contains_key("active/repo"),
5172 "global sweep must not select tasks for the active repo"
5173 );
5174 let inactive_ids: std::collections::HashSet<i64> = by_repo
5175 .get("inactive/repo")
5176 .expect("inactive/repo must be selected")
5177 .iter()
5178 .map(|t| t.id)
5179 .collect();
5180 assert_eq!(
5181 inactive_ids,
5182 [inactive_id_a, inactive_id_b].into_iter().collect(),
5183 "global sweep must group all orphaned tasks for a repo together"
5184 );
5185 assert_eq!(
5186 by_repo
5187 .get("other-inactive/repo")
5188 .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
5189 Some(vec![other_inactive_id])
5190 );
5191
5192 assert_eq!(
5195 store.get(active_id).await.unwrap().status,
5196 TaskStatus::Routed
5197 );
5198 }
5199
5200 #[tokio::test]
5205 async fn group_orphaned_routed_tasks_excludes_all_active_repos_not_just_current() {
5206 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
5207
5208 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5209
5210 async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
5211 let id = store
5212 .upsert_external(&UpsertExternal {
5213 repo,
5214 ext_id,
5215 title: "Routed task",
5216 body: "",
5217 author: "user",
5218 url: &format!("https://github.com/{repo}/issues/{ext_id}"),
5219 labels: &[],
5220 origin: "github",
5221 })
5222 .await
5223 .unwrap();
5224 store
5225 .store_route(&StoreRoute {
5226 id,
5227 agent: "claude",
5228 model: Some("sonnet"),
5229 complexity: "medium",
5230 estimate: 0,
5231 reason: "test",
5232 profile: "{}",
5233 skills: "[]",
5234 })
5235 .await
5236 .unwrap();
5237 store.update_status(id, TaskStatus::Routed).await.unwrap();
5238 id
5239 }
5240
5241 let repo_a_id = make_routed(&store, "active/repo-a", "1").await;
5242 let repo_b_id = make_routed(&store, "active/repo-b", "2").await;
5243 let orphaned_id = make_routed(&store, "inactive/repo", "3").await;
5244
5245 let all_routed = store
5246 .list_all_by_status_global(TaskStatus::Routed)
5247 .await
5248 .unwrap();
5249 let active_repos: std::collections::HashSet<String> =
5252 ["active/repo-a".to_string(), "active/repo-b".to_string()]
5253 .into_iter()
5254 .collect();
5255 let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
5256
5257 assert!(
5258 !by_repo.contains_key("active/repo-a"),
5259 "the current repo's own tasks must not be swept"
5260 );
5261 assert!(
5262 !by_repo.contains_key("active/repo-b"),
5263 "another active repo's tasks must not be swept during repo A's tick"
5264 );
5265 assert_eq!(
5266 by_repo
5267 .get("inactive/repo")
5268 .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
5269 Some(vec![orphaned_id]),
5270 "a genuinely inactive repo's tasks must still be swept"
5271 );
5272
5273 assert_eq!(
5275 store.get(repo_a_id).await.unwrap().status,
5276 TaskStatus::Routed
5277 );
5278 assert_eq!(
5279 store.get(repo_b_id).await.unwrap().status,
5280 TaskStatus::Routed
5281 );
5282 }
5283
5284 #[tokio::test]
5286 async fn global_dispatch_sweep_skips_no_agent_tasks() {
5287 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
5288
5289 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5290
5291 let id = store
5292 .upsert_external(&UpsertExternal {
5293 repo: "inactive/repo",
5294 ext_id: "99",
5295 title: "No-agent routed task",
5296 body: "",
5297 author: "user",
5298 url: "https://github.com/inactive/repo/issues/99",
5299 labels: &["no-agent".to_string()],
5300 origin: "github",
5301 })
5302 .await
5303 .unwrap();
5304 store
5305 .store_route(&StoreRoute {
5306 id,
5307 agent: "claude",
5308 model: Some("sonnet"),
5309 complexity: "medium",
5310 estimate: 0,
5311 reason: "test",
5312 profile: "{}",
5313 skills: "[]",
5314 })
5315 .await
5316 .unwrap();
5317 store.update_status(id, TaskStatus::Routed).await.unwrap();
5318
5319 let tmux = Arc::new(TmuxManager::new());
5324 let transport = Arc::new(Transport::new());
5325 let capture = Arc::new(CaptureService::new(transport));
5326 let semaphore = Arc::new(Semaphore::new(4));
5327 let (weight_tx, _weight_rx) = mpsc::channel(16);
5328 let dispatching = Arc::new(DashMap::new());
5329 let dispatch_mode = DispatchMode {
5330 is_degraded: false,
5331 healthy_agents: 1,
5332 threshold: 1,
5333 };
5334
5335 let active_repos: std::collections::HashSet<String> =
5336 ["active/repo".to_string()].into_iter().collect();
5337 dispatch_routed_tasks_global(
5338 &tmux,
5339 &capture,
5340 &semaphore,
5341 &weight_tx,
5342 dispatch_mode,
5343 &dispatching,
5344 &store,
5345 &std::collections::HashMap::new(),
5346 &active_repos,
5347 )
5348 .await
5349 .unwrap();
5350
5351 assert_eq!(
5352 store.get(id).await.unwrap().status,
5353 TaskStatus::Routed,
5354 "no-agent tasks must not be dispatched"
5355 );
5356 }
5357}