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