1use crate::backends::{ExternalBackend, ExternalId, ExternalTask, Status};
17use crate::channels::capture::CaptureService;
18use crate::config;
19use crate::engine::cooldown::{
20 SILENCE_AGENT_COOLDOWN_SECS, SILENCE_EXTENDED_COOLDOWN_SECS, github_circuit_remaining_secs,
21 is_agent_in_cooldown, is_github_circuit_open, is_model_in_cooldown,
22 record_agent_failure_with_message, record_silence_detection, set_agent_cooldown,
23 set_model_cooldown,
24};
25use crate::engine::dispatch_guard::DispatchGuard;
26use crate::engine::jobs;
27use crate::engine::router::{AllAgentsCooledError, RouteResultError, Router, get_route_result};
28use crate::engine::runner::{TaskRunner, WeightSignal};
29use crate::engine::tasks::{TaskManager, is_internal_id};
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::{Semaphore, mpsc};
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
284fn stuck_task_timing_from_map(
285 tmux: &Arc<TmuxManager>,
286 repo: &str,
287 session_task_id: &str,
288 updated_at: &str,
289 config: &EngineConfig,
290 parse_error_message: &'static str,
291 session_map: &std::collections::HashMap<String, bool>,
292) -> Option<StuckTaskTiming> {
293 let session_name = tmux.session_name(repo, session_task_id);
294 let has_session = session_map.get(&session_name).copied().unwrap_or(false);
296 let threshold = if has_session {
297 config.stuck_timeout
298 } else {
299 config.no_session_stuck_timeout
300 };
301
302 let updated = match chrono::DateTime::parse_from_rfc3339(updated_at) {
303 Ok(dt) => dt.with_timezone(&chrono::Utc),
304 Err(e) => {
305 tracing::warn!(
306 task_id = session_task_id,
307 updated_at,
308 ?e,
309 error_message = parse_error_message,
310 "cannot parse updated_at"
311 );
312 return None;
313 }
314 };
315
316 let raw_age = chrono::Utc::now() - updated;
317 let suspended = super::suspend::suspended_duration_since(updated);
321 let age = (raw_age - suspended).max(chrono::Duration::zero());
322 if age.num_seconds() <= i64::try_from(threshold).unwrap_or(i64::MAX) {
323 return None;
324 }
325
326 Some(StuckTaskTiming {
327 has_session,
328 age,
329 threshold,
330 })
331}
332
333async fn in_review_stuck_entry(store: &TaskStore, store_id: i64, fallback: &str) -> (String, bool) {
336 match store.in_review_entry(store_id).await {
337 Ok(Some(entry)) => entry,
338 Ok(None) => (fallback.to_string(), false),
339 Err(e) => {
340 tracing::warn!(
341 store_id,
342 error = %e,
343 "failed to read in_review entry, falling back to updated_at"
344 );
345 (fallback.to_string(), false)
346 }
347 }
348}
349
350async fn finalize_open_runs_for_recovery(store: &TaskStore, store_id: i64, reason: &str) {
353 if let Err(e) = store
354 .finalize_incomplete_runs(store_id, "aborted", reason)
355 .await
356 {
357 tracing::warn!(
358 store_id,
359 error = %e,
360 reason,
361 "failed to finalize incomplete runs during recovery"
362 );
363 }
364}
365
366pub(crate) async fn tick_detect_silent_agents(
375 tmux: &Arc<TmuxManager>,
376 repo: &str,
377 capture: &Arc<CaptureService>,
378 backend: &Arc<dyn ExternalBackend>,
379 task_manager: &Arc<TaskManager>,
380 config: &EngineConfig,
381 store: &Arc<TaskStore>,
382) -> anyhow::Result<()> {
383 let _span = tracing::info_span!("engine.tick.phase1b.silence").entered();
384 let grace = std::time::Duration::from_secs(config.silence_grace_period);
385 let silent_sessions = capture.get_silent_sessions_for_repo(repo, grace).await;
386
387 for (task_id, session_name, silence_age_secs) in silent_sessions {
388 let use_backend = should_use_backend(&task_id);
389 let store_task = match store.resolve_task_id(repo, &task_id).await {
391 Ok(Some(store_id)) => match store.get(store_id).await {
392 Ok(task) => Some(task),
393 Err(e) => {
394 tracing::warn!(
395 task_id,
396 repo,
397 store_id,
398 operation = "store.get",
399 error = %e,
400 "silence detection: db error fetching task — agent/model cooldown will be skipped"
401 );
402 None
403 }
404 },
405 Ok(None) => {
406 tracing::debug!(
407 task_id,
408 repo,
409 operation = "resolve_task_id",
410 "silence detection: task not found in store (may have been cleaned up)"
411 );
412 None
413 }
414 Err(e) => {
415 tracing::warn!(
416 task_id,
417 repo,
418 operation = "resolve_task_id",
419 error = %e,
420 "silence detection: db error resolving task id — agent/model cooldown will be skipped"
421 );
422 None
423 }
424 };
425 let agent_name = store_task
426 .as_ref()
427 .and_then(|t| t.agent.clone())
428 .unwrap_or_default();
429 let model_name = store_task
430 .as_ref()
431 .and_then(|t| t.model.clone())
432 .unwrap_or_default();
433
434 tracing::warn!(
435 task_id,
436 agent = %agent_name,
437 model = %model_name,
438 silence_age_secs,
439 grace_secs = config.silence_grace_period,
440 cooldown_secs = config.silence_cooldown,
441 "agent silent since session start — killing session, cooling down model + agent, failing over"
442 );
443
444 if let Err(e) = tmux.kill_session(&session_name).await {
446 tracing::debug!(
447 task_id,
448 ?e,
449 "kill_session failed for silent agent (may already be gone)"
450 );
451 }
452
453 capture.unregister_session(repo, &task_id).await;
455
456 if let Some(store_id) = store_task.as_ref().map(|t| t.id) {
458 finalize_open_runs_for_recovery(store, store_id, "silence detection: session killed")
459 .await;
460 }
461
462 let mut extended_note = String::new();
463
464 if !agent_name.is_empty() && !model_name.is_empty() {
466 set_model_cooldown(
467 &agent_name,
468 &model_name,
469 config.silence_cooldown,
470 "silence_detected",
471 )
472 .await;
473 if let Some(result) = record_silence_detection(&agent_name, &model_name).await
474 && result.extended_cooldown_applied
475 {
476 extended_note = format!(
477 " ({} silences in 24h -> extended cooldown {}s)",
478 result.count, SILENCE_EXTENDED_COOLDOWN_SECS
479 );
480 }
481 }
482
483 if !agent_name.is_empty() {
487 set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
488 }
489
490 let task_eid = ExternalId(task_id.clone());
495
496 let available: Vec<String> = crate::engine::configured_agents()
498 .into_iter()
499 .filter(|a| crate::cmd_cache::command_exists(a))
500 .collect();
501 let chain = crate::engine::runner::response::get_reroute_chain(
502 &task_id,
503 &Some(Arc::clone(store)),
504 repo,
505 )
506 .await;
507 let chain = crate::engine::runner::response::update_reroute_chain(
508 &task_id,
509 &agent_name,
510 &chain,
511 &Some(Arc::clone(store)),
512 repo,
513 )
514 .await;
515
516 let (next_status, next_agent) = if let Some(fallback) =
517 crate::engine::runner::response::pick_fallback_agent(&agent_name, &chain, &available)
518 {
519 tracing::info!(
520 task_id,
521 from = %agent_name,
522 to = %fallback,
523 "silence detection: failover to different agent, setting routed"
524 );
525 (Status::Routed, Some(fallback))
526 } else {
527 tracing::warn!(
528 task_id,
529 "silence detection: no fallback agents available, resetting to new for re-routing"
530 );
531 (Status::New, None)
532 };
533
534 if use_backend && let Some(ref st) = store_task {
535 let stale_labels: Vec<String> = st
537 .labels
538 .iter()
539 .filter(|l| {
540 l.starts_with("agent:")
541 || l.starts_with("complexity:")
542 || l.starts_with("model:")
543 })
544 .cloned()
545 .collect();
546 if !stale_labels.is_empty() {
547 let backend_clone = Arc::clone(backend);
548 let task_eid_clone = task_eid.clone();
549 tokio::spawn(async move {
550 for label in &stale_labels {
551 if let Err(e) = backend_clone.remove_label(&task_eid_clone, label).await {
552 tracing::warn!(task_id = task_eid_clone.0, label, error = %e, "failed to remove label during silence detection re-route");
553 }
554 }
555 });
556 }
557 }
558
559 if let Some(store_id) = store_task.as_ref().map(|t| t.id) {
560 if let Some(ref fallback) = next_agent {
561 let _ = store_set_by_id(
565 Some(store),
566 store_id,
567 &[
568 ("agent", serde_json::json!(fallback)),
569 ("model", serde_json::json!("")),
570 (
571 "last_error",
572 serde_json::json!(format!(
573 "silence detected after {}s, failing over to {}",
574 config.silence_grace_period, fallback
575 )),
576 ),
577 ],
578 )
579 .await;
580 } else {
581 let _ = store_set_by_id(
582 Some(store),
583 store_id,
584 &[
585 ("agent", serde_json::Value::Null),
586 ("model", serde_json::Value::Null),
587 (
588 "last_error",
589 serde_json::json!(format!(
590 "silence detected after {}s, no fallback agents available",
591 config.silence_grace_period
592 )),
593 ),
594 ],
595 )
596 .await;
597 }
598 }
599
600 if let Err(e) = task_manager
601 .update_task_status(&task_eid, next_status)
602 .await
603 {
604 tracing::warn!(task_id, ?e, "failed to update silent task status");
605 continue;
606 }
607
608 let details = serde_json::json!({
610 "failure_reason": "agent_silence",
611 "silence_duration_secs": config.silence_grace_period,
612 "cooldown_applied": !agent_name.is_empty() && !model_name.is_empty(),
613 "fallback_available": next_agent.is_some(),
614 });
615 crate::store::store_log_activity(
616 &Some(Arc::clone(store)),
617 repo,
618 &task_id,
619 "rerouted",
620 Some("in_progress"),
621 Some(next_status.as_str()),
622 next_agent.as_deref(),
623 None,
624 Some(&details),
625 )
626 .await;
627
628 let action = if let Some(ref fallback) = next_agent {
630 format!("failing over to {fallback}")
631 } else {
632 "marking needs_review (no fallback agents)".to_string()
633 };
634 let comment = format!(
635 "[{}] agent silent for {}s since session start — killed session, cooled down model `{}:{}` for {}s{}, agent `{}` for {}s, {}{}",
636 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
637 config.silence_grace_period,
638 agent_name,
639 model_name,
640 config.silence_cooldown,
641 extended_note,
642 agent_name,
643 SILENCE_AGENT_COOLDOWN_SECS,
644 action,
645 crate::engine::orch_footer(),
646 );
647 if use_backend {
648 let backend_clone = Arc::clone(backend);
650 let task_eid_clone = task_eid.clone();
651 tokio::spawn(async move {
652 if let Err(e) = backend_clone.post_comment(&task_eid_clone, &comment).await {
653 tracing::warn!(
654 task_id = task_eid_clone.0,
655 ?e,
656 "failed to post silence detection comment"
657 );
658 }
659 });
660 }
661 }
662
663 Ok(())
664}
665
666fn should_use_backend(task_id: &str) -> bool {
667 !is_internal_id(task_id)
668}
669
670#[expect(
675 clippy::too_many_arguments,
676 reason = "each argument is engine state the tick passes down"
677)]
678pub(crate) async fn tick_recover_stuck_tasks(
679 backend: &Arc<dyn ExternalBackend>,
680 tmux: &Arc<TmuxManager>,
681 repo: &str,
682 task_manager: &Arc<TaskManager>,
683 config: &EngineConfig,
684 store: &Arc<TaskStore>,
685 session_map: &std::collections::HashMap<String, bool>,
686 dispatching: &Arc<DashMap<String, String>>,
687) -> anyhow::Result<()> {
688 use crate::store::TaskStatus as DbStatus;
689
690 let _span = tracing::info_span!("engine.tick.phase2.stuck_tasks").entered();
691
692 let in_progress = match task_manager
693 .list_external_by_status(Status::InProgress)
694 .await
695 {
696 Ok(tasks) => tasks,
697 Err(e) => {
698 tracing::warn!(?e, "failed to list in_progress tasks for stuck recovery");
699 vec![]
700 }
701 };
702 for task in &in_progress {
703 let Some(timing) = stuck_task_timing_from_map(
704 tmux,
705 repo,
706 &task.id.0,
707 &task.updated_at,
708 config,
709 "cannot parse updated_at, skipping stuck-task check",
710 session_map,
711 ) else {
712 continue;
713 };
714
715 if !timing.has_session {
722 let dispatch_key = format!("{repo}/{}", task.id.0);
723 if dispatching.contains_key(&dispatch_key) {
724 tracing::info!(
732 task_id = task.id.0,
733 dispatch_key,
734 age_mins = timing.age.num_minutes(),
735 "skipping stuck-task reclaim: runner still actively processing this task"
736 );
737 continue;
738 }
739 }
740
741 if timing.has_session {
742 tracing::warn!(
743 task_id = task.id.0,
744 age_mins = timing.age.num_minutes(),
745 threshold_mins = timing.threshold / 60,
746 "recovering stuck task: timed out with active session → new"
747 );
748 } else {
749 tracing::warn!(
750 task_id = task.id.0,
751 dispatch_key = format!("{repo}/{}", task.id.0),
752 age_mins = timing.age.num_minutes(),
753 threshold_mins = timing.threshold / 60,
754 "recovering stuck task: no session found — reclaiming early → new"
755 );
756 }
757 let mut cached_store_id: Option<i64> = None;
762 if timing.has_session {
763 let store_task = match store.resolve_task_id(repo, &task.id.0).await {
764 Ok(Some(store_id)) => {
765 cached_store_id = Some(store_id);
766 match store.get(store_id).await {
767 Ok(t) => Some(t),
768 Err(e) => {
769 tracing::warn!(task_id = task.id.0, error = %e, "failed to fetch task from store for stuck-task cooldown");
770 None
771 }
772 }
773 }
774 Ok(None) => None,
775 Err(e) => {
776 tracing::warn!(task_id = task.id.0, error = %e, "failed to resolve task id for stuck-task cooldown");
777 None
778 }
779 };
780 let agent_name = store_task
781 .as_ref()
782 .and_then(|t| t.agent.clone())
783 .unwrap_or_default();
784 let model_name = store_task
785 .as_ref()
786 .and_then(|t| t.model.clone())
787 .unwrap_or_default();
788
789 if !agent_name.is_empty() && !model_name.is_empty() {
790 set_model_cooldown(
791 &agent_name,
792 &model_name,
793 config.silence_cooldown,
794 "silence_detected",
795 )
796 .await;
797 record_agent_failure_with_message(
798 &agent_name,
799 &format!(
800 "stuck with active session after {}m",
801 timing.age.num_minutes()
802 ),
803 )
804 .await;
805 }
806 if !agent_name.is_empty() {
807 set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
808 }
809 tracing::warn!(
810 task_id = task.id.0,
811 agent = %agent_name,
812 model = %model_name,
813 cooldown_secs = config.silence_cooldown,
814 "stuck-task cooldown applied — router will avoid this agent/model on next attempt"
815 );
816 }
817
818 {
821 let stale_labels: Vec<String> = task
822 .labels
823 .iter()
824 .filter(|l| l.starts_with("agent:") || l.starts_with("model:"))
825 .cloned()
826 .collect();
827 if !stale_labels.is_empty() {
828 let backend_clone = Arc::clone(backend);
829 let task_id_clone = task.id.clone();
830 tokio::spawn(async move {
831 for label in &stale_labels {
832 if let Err(e) = backend_clone.remove_label(&task_id_clone, label).await {
833 tracing::warn!(task_id = task_id_clone.0, label, error = %e, "failed to remove stale routing label during stuck-task recovery");
834 }
835 }
836 });
837 }
838 }
839 let resolved_store_id = match cached_store_id {
840 Some(id) => Some(id),
841 None => match store.resolve_task_id(repo, &task.id.0).await {
842 Ok(Some(id)) => Some(id),
843 Ok(None) => {
844 tracing::warn!(
845 task_id = %task.id.0,
846 repo,
847 "resolve_task_id returned None during stuck-task recovery"
848 );
849 None
850 }
851 Err(e) => {
852 tracing::error!(
853 task_id = %task.id.0,
854 repo,
855 error = %e,
856 "resolve_task_id failed during stuck-task recovery"
857 );
858 None
859 }
860 },
861 };
862 if let Some(store_id) = resolved_store_id {
863 if let Err(e) = store_set_by_id(
864 Some(store),
865 store_id,
866 &[
867 ("agent", serde_json::Value::Null),
868 ("model", serde_json::Value::Null),
869 ("route_attempts", serde_json::json!(0)),
870 ],
871 )
872 .await
873 {
874 tracing::warn!(
875 task_id = task.id.0,
876 ?e,
877 "failed to clear routing fields for stuck task — will retry next tick"
878 );
879 continue;
880 }
881 finalize_open_runs_for_recovery(
882 store,
883 store_id,
884 if timing.has_session {
885 "stuck-task recovery: active session killed"
886 } else {
887 "stuck-task recovery: no session found"
888 },
889 )
890 .await;
891 }
892 if let Err(e) = task_manager.update_task_status(&task.id, Status::New).await {
893 tracing::warn!(task_id = task.id.0, ?e, "failed to reset stuck task status");
894 continue;
895 }
896
897 let details = serde_json::json!({
899 "failure_reason": if timing.has_session { "stuck_with_session" } else { "stuck_no_session" },
900 "age_minutes": timing.age.num_minutes(),
901 "had_session": timing.has_session,
902 });
903 crate::store::store_log_activity(
904 &Some(Arc::clone(store)),
905 repo,
906 &task.id.0,
907 "rerouted",
908 Some("in_progress"),
909 Some("new"),
910 None::<&str>,
911 None::<&str>,
912 Some(&details),
913 )
914 .await;
915
916 let reason = if timing.has_session {
917 format!(
918 "timed out after {}m with active session (cleared agent for re-routing)",
919 timing.age.num_minutes()
920 )
921 } else {
922 format!(
923 "no session found — reclaiming early after {}m (cleared agent for re-routing)",
924 timing.age.num_minutes()
925 )
926 };
927 {
929 let backend_clone = Arc::clone(backend);
930 let task_id_clone = task.id.clone();
931 let body = format!(
932 "[{}] recovered: stuck in_progress — {}{}",
933 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
934 reason,
935 crate::engine::orch_footer()
936 );
937 tokio::spawn(async move {
938 if let Err(e) = backend_clone.post_comment(&task_id_clone, &body).await {
939 tracing::warn!(
940 task_id = task_id_clone.0,
941 ?e,
942 "failed to post stuck-task recovery comment"
943 );
944 }
945 });
946 }
947 }
948
949 let mut routed = match task_manager.list_external_by_status(Status::Routed).await {
950 Ok(tasks) => tasks,
951 Err(e) => {
952 tracing::warn!(?e, "failed to list routed tasks for stuck recovery");
953 vec![]
954 }
955 };
956 match task_manager
957 .list_internal_by_status(crate::store::TaskStatus::Routed)
958 .await
959 {
960 Ok(tasks) => routed.extend(tasks),
961 Err(e) => tracing::warn!(
962 ?e,
963 "failed to list internal routed tasks for stuck recovery"
964 ),
965 }
966 for task in &routed {
967 let Some(timing) = stuck_task_timing_from_map(
968 tmux,
969 repo,
970 &task.id.0,
971 &task.updated_at,
972 config,
973 "cannot parse updated_at, skipping routed stuck-task check",
974 session_map,
975 ) else {
976 continue;
977 };
978
979 if !timing.has_session {
980 continue;
981 }
982
983 let session_name = tmux.session_name(repo, &task.id.0);
984 if let Err(e) = tmux.kill_session(&session_name).await {
985 tracing::warn!(
986 task_id = task.id.0,
987 session = %session_name,
988 error = %e,
989 "failed to kill stale tmux session blocking routed task"
990 );
991 continue;
992 }
993
994 recover_routed_blocked_dispatch(backend, store, task_manager, repo, task, &timing).await;
995 }
996
997 let 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 && let Err(e) =
1667 backend_clone.remove_label(&task_id_clone, label).await
1668 {
1669 tracing::warn!(task_id = task_id_clone.0, label, error = %e, "failed to remove stale routing label during re-route");
1670 }
1671 }
1672 if let Err(e) = backend_clone.set_labels(&task_id_clone, &new_labels).await
1674 {
1675 tracing::warn!(
1676 task_id = task_id_clone.0,
1677 ?e,
1678 "failed to set routing labels"
1679 );
1680 }
1681 });
1682
1683 if let Err(e) = task_manager
1685 .update_task_status(&task.id, Status::Routed)
1686 .await
1687 {
1688 tracing::warn!(task_id = task.id.0, ?e, "failed to set status:routed");
1689 continue;
1690 }
1691 }
1692
1693 match store.ensure_external_task(repo, task).await {
1695 Ok(store_id) => {
1696 let profile_json =
1697 serde_json::to_string(&result.profile).unwrap_or_default();
1698 let skills_json =
1699 serde_json::to_string(&result.selected_skills).unwrap_or_default();
1700 if let Err(e) = store
1701 .store_route(&crate::store::StoreRoute {
1702 id: store_id,
1703 agent: &result.agent,
1704 model: result.model.as_deref(),
1705 complexity: &result.complexity,
1706 estimate: result.estimate,
1707 reason: &result.reason,
1708 profile: &profile_json,
1709 skills: &skills_json,
1710 })
1711 .await
1712 {
1713 tracing::debug!(
1714 task_id = task.id.0,
1715 ?e,
1716 "dual-write: store_route failed"
1717 );
1718 }
1719 if let Err(e) = store
1720 .update_status(store_id, crate::store::TaskStatus::Routed)
1721 .await
1722 {
1723 tracing::debug!(
1724 task_id = task.id.0,
1725 ?e,
1726 "dual-write: update_status failed"
1727 );
1728 }
1729 if result.estimate > 0 {
1733 let backend_clone = Arc::clone(backend);
1734 let task_id = task.id.clone();
1735 let estimate = result.estimate;
1736 tokio::spawn(async move {
1737 if let Err(e) = backend_clone
1738 .sync_estimate_to_project(&task_id, estimate)
1739 .await
1740 {
1741 tracing::debug!(
1742 task_id = task_id.0,
1743 estimate,
1744 err = %e,
1745 "dual-write: estimate project sync failed"
1746 );
1747 }
1748 });
1749 }
1750 }
1751 Err(e) => {
1752 tracing::debug!(
1753 task_id = task.id.0,
1754 ?e,
1755 "dual-write: ensure_external_task failed"
1756 );
1757 }
1758 }
1759
1760 if let Some(ref warning) = result.warning {
1761 tracing::warn!(task_id = task.id.0, warning, "routing sanity warning");
1762 }
1763
1764 tracing::info!(
1765 task_id = task.id.0,
1766 agent = %result.agent,
1767 complexity = %result.complexity,
1768 reason = %result.reason,
1769 "task routed"
1770 );
1771 }
1772 Err(e) => {
1773 if let Some(cooled) = e.downcast_ref::<AllAgentsCooledError>() {
1774 tracing::error!(
1775 task_id = %task.id.0,
1776 scope = %cooled.scope(),
1777 remaining_secs = ?cooled.remaining_secs(),
1778 degraded = ?cooled.degraded(),
1779 "ALL AGENTS COOLED — no agent available to route this task. \
1780 Task stays in 'new' and will be retried on every tick. \
1781 Check `orch cooldown list` to see what is blocking dispatch."
1782 );
1783 continue;
1784 }
1785 if let Some(waiting) = e.downcast_ref::<crate::store::WaitingOnDependency>() {
1786 tracing::info!(
1787 task_id = %task.id.0,
1788 blockers = %waiting.blockers.join(", "),
1789 "task is waiting on a dependency"
1790 );
1791 continue;
1792 }
1793 tracing::error!(task_id = task.id.0, ?e, "routing failed, skipping task");
1794 }
1795 }
1796 }
1797 Ok(())
1798}
1799
1800pub(crate) async fn route_new_tasks_global(
1816 router: &mut Router,
1817 store: &Arc<TaskStore>,
1818 active_repos: &std::collections::HashSet<String>,
1819 routing_quota: &mut RoutingTickQuota,
1820) -> anyhow::Result<()> {
1821 use crate::store::{StoreRoute, TaskStatus};
1822
1823 if routing_quota.is_exhausted() {
1824 tracing::debug!("routing quota exhausted for this tick — skipping global routing sweep");
1825 return Ok(());
1826 }
1827
1828 let all_new = store.list_all_by_status_global(TaskStatus::New).await?;
1829 let orphaned: Vec<&crate::store::Task> = all_new
1830 .iter()
1831 .filter(|t| !active_repos.contains(&t.repo))
1832 .filter(|t| !skips_agent(&t.labels))
1833 .collect();
1834
1835 if orphaned.is_empty() {
1836 return Ok(());
1837 }
1838
1839 tracing::info!(
1840 count = orphaned.len(),
1841 "global routing sweep: found new tasks from inactive repos"
1842 );
1843
1844 router.refresh_health(store).await;
1845
1846 for task in orphaned.into_iter().take(routing_quota.remaining()) {
1847 if !routing_quota.consume_one() {
1848 break;
1849 }
1850 let ext_task = crate::engine::tasks::store_task_to_external(task);
1851 let task_start = Instant::now();
1852 match router.route(&ext_task, store, &task.repo).await {
1853 Ok(result) => {
1854 tracing::debug!(
1855 task_id = %ext_task.id.0,
1856 repo = %task.repo,
1857 duration_ms = task_start.elapsed().as_millis(),
1858 "global sweep: route completed"
1859 );
1860 let profile_json = serde_json::to_string(&result.profile).unwrap_or_default();
1861 let skills_json =
1862 serde_json::to_string(&result.selected_skills).unwrap_or_default();
1863 if let Err(e) = store
1864 .store_route(&StoreRoute {
1865 id: task.id,
1866 agent: &result.agent,
1867 model: result.model.as_deref(),
1868 complexity: &result.complexity,
1869 estimate: result.estimate,
1870 reason: &result.reason,
1871 profile: &profile_json,
1872 skills: &skills_json,
1873 })
1874 .await
1875 {
1876 tracing::warn!(
1877 task_id = %ext_task.id.0,
1878 repo = %task.repo,
1879 err = %e,
1880 "global sweep: failed to store route result; skipping status update"
1881 );
1882 continue;
1883 }
1884 match store.update_status(task.id, TaskStatus::Routed).await {
1885 Err(e) => {
1886 tracing::warn!(
1887 task_id = %ext_task.id.0,
1888 repo = %task.repo,
1889 err = %e,
1890 "global sweep: failed to set status:routed"
1891 );
1892 }
1893 _ => {
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 }
1903 if let Some(ref warning) = result.warning {
1904 tracing::warn!(task_id = %ext_task.id.0, warning, "global sweep: routing sanity warning");
1905 }
1906 }
1907 Err(e) => {
1908 if let Some(cooled) = e.downcast_ref::<AllAgentsCooledError>() {
1909 tracing::error!(
1910 task_id = %ext_task.id.0,
1911 repo = %task.repo,
1912 scope = %cooled.scope(),
1913 remaining_secs = ?cooled.remaining_secs(),
1914 degraded = ?cooled.degraded(),
1915 "global sweep: ALL AGENTS COOLED — task stays in 'new'"
1916 );
1917 continue;
1918 }
1919 if let Some(waiting) = e.downcast_ref::<crate::store::WaitingOnDependency>() {
1920 tracing::info!(
1921 task_id = %ext_task.id.0,
1922 repo = %task.repo,
1923 blockers = %waiting.blockers.join(", "),
1924 "global sweep: task is waiting on a dependency"
1925 );
1926 continue;
1927 }
1928 tracing::error!(
1929 task_id = %ext_task.id.0,
1930 repo = %task.repo,
1931 err = ?e,
1932 "global sweep: routing failed, skipping task"
1933 );
1934 }
1935 }
1936 }
1937
1938 Ok(())
1939}
1940
1941#[derive(Debug, Clone, Copy)]
1943pub(crate) struct DispatchMode {
1944 pub(crate) is_degraded: bool,
1945 pub(crate) healthy_agents: usize,
1946 pub(crate) threshold: usize,
1947}
1948
1949pub(crate) fn dispatch_mode_from_router(router: &Router) -> DispatchMode {
1950 let threshold = crate::engine::router::config::min_healthy_agents_threshold();
1951 let healthy_agents = router.healthy_agent_count("simple");
1952 let is_degraded = router.is_degraded(threshold);
1953 DispatchMode {
1954 is_degraded,
1955 healthy_agents,
1956 threshold,
1957 }
1958}
1959
1960#[expect(
1962 clippy::too_many_arguments,
1963 reason = "each argument is engine state the tick passes down"
1964)]
1965#[tracing::instrument(skip_all, name = "engine.tick.phase3b.dispatch")]
1966pub(crate) async fn tick_dispatch_tasks(
1967 backend: &Arc<dyn ExternalBackend>,
1968 tmux: &Arc<TmuxManager>,
1969 repo: &str,
1970 runner: &Arc<TaskRunner>,
1971 capture: &Arc<CaptureService>,
1972 semaphore: &Arc<Semaphore>,
1973 task_manager: &Arc<TaskManager>,
1974 weight_tx: &mpsc::Sender<WeightSignal>,
1975 dispatch_mode: DispatchMode,
1976 dispatching: &Arc<DashMap<String, String>>,
1977 store: &Arc<TaskStore>,
1978 session_map: &std::collections::HashMap<String, bool>,
1979) -> anyhow::Result<()> {
1980 let mut routed_tasks = task_manager.list_external_by_status(Status::Routed).await?;
1982
1983 use crate::store::TaskStatus as DbStatus;
1985 let internal_routed = task_manager
1986 .list_internal_by_status(DbStatus::Routed)
1987 .await?;
1988 routed_tasks.extend(internal_routed);
1989
1990 let dispatchable: Vec<ExternalTask> = routed_tasks
1992 .into_iter()
1993 .filter(|t| !skips_agent(&t.labels))
1994 .collect();
1995
1996 dispatch_tasks_for_repo(
1997 backend,
1998 tmux,
1999 repo,
2000 runner,
2001 capture,
2002 semaphore,
2003 task_manager,
2004 weight_tx,
2005 dispatch_mode,
2006 dispatching,
2007 store,
2008 session_map,
2009 dispatchable,
2010 )
2011 .await
2012}
2013
2014#[expect(
2020 clippy::too_many_arguments,
2021 reason = "each argument is engine state the tick passes down"
2022)]
2023async fn dispatch_tasks_for_repo(
2024 backend: &Arc<dyn ExternalBackend>,
2025 tmux: &Arc<TmuxManager>,
2026 repo: &str,
2027 runner: &Arc<TaskRunner>,
2028 capture: &Arc<CaptureService>,
2029 semaphore: &Arc<Semaphore>,
2030 task_manager: &Arc<TaskManager>,
2031 weight_tx: &mpsc::Sender<WeightSignal>,
2032 dispatch_mode: DispatchMode,
2033 dispatching: &Arc<DashMap<String, String>>,
2034 store: &Arc<TaskStore>,
2035 session_map: &std::collections::HashMap<String, bool>,
2036 dispatchable: Vec<ExternalTask>,
2037) -> anyhow::Result<()> {
2038 if dispatchable.is_empty() {
2039 tracing::debug!(count = 0, "dispatchable tasks found");
2040 return Ok(());
2041 }
2042
2043 tracing::info!(count = dispatchable.len(), "dispatchable tasks found");
2044 if dispatch_mode.is_degraded {
2045 tracing::warn!(
2046 healthy_agents = dispatch_mode.healthy_agents,
2047 threshold = dispatch_mode.threshold,
2048 "degraded mode: using sequential dispatch"
2049 );
2050 }
2051
2052 let sequential_delay = if dispatch_mode.is_degraded {
2053 crate::engine::router::config::sequential_dispatch_delay_ms()
2054 } else {
2055 0
2056 };
2057
2058 for (idx, task) in dispatchable.into_iter().enumerate() {
2059 if crate::engine::dependencies::dispatch_is_waiting(store, repo, &task.id.0).await {
2060 continue;
2061 }
2062 if idx > 0 && dispatch_mode.is_degraded {
2064 let delay_ms = sequential_delay;
2065 tracing::debug!(
2066 task_id = task.id.0,
2067 delay_ms = delay_ms,
2068 "sequential dispatch: waiting before dispatch"
2069 );
2070 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
2071 }
2072 let dispatch_key = format!("{}/{}", repo, task.id.0);
2079 {
2084 use dashmap::mapref::entry::Entry;
2085 match dispatching.entry(dispatch_key.clone()) {
2086 Entry::Occupied(existing) => {
2087 let existing_id = existing.get().clone();
2088 drop(existing); if existing_id == task.id.0 {
2090 tracing::debug!(
2091 task_id = task.id.0,
2092 "task already dispatching, skipping duplicate"
2093 );
2094 } else {
2095 tracing::warn!(
2096 task_id = task.id.0,
2097 existing_task_id = existing_id,
2098 dispatch_key,
2099 "dispatch key collision: unexpected task already holds this key"
2100 );
2101 }
2102 continue;
2103 }
2104 Entry::Vacant(slot) => {
2105 slot.insert(task.id.0.clone());
2106 tracing::info!(task_id = task.id.0, dispatch_key, "dispatch guard claimed");
2113 }
2114 }
2115 }
2116 let dispatch_guard =
2118 DispatchGuard::new(std::sync::Arc::clone(dispatching), dispatch_key.clone());
2119 let task_id = task.id.0.clone();
2120
2121 let route_result = match get_route_result(store, repo, &task_id).await {
2124 Ok(r) => Some(r),
2125 Err(RouteResultError::NoAgent { .. }) => {
2126 tracing::warn!(
2127 task_id,
2128 "routed task missing agent — resetting to new for re-routing (#1604)"
2129 );
2130 if let Err(e2) = task_manager.update_task_status(&task.id, Status::New).await {
2131 tracing::error!(task_id, error = %e2, "failed to reset task to new");
2132 }
2133 continue; }
2135 Err(e) => {
2136 tracing::warn!(task_id, error = %e, "get_route_result failed — dispatching without route info");
2137 None
2138 }
2139 };
2140
2141 if let Some(route) = route_result.as_ref() {
2147 let agent_cooled = is_agent_in_cooldown(&route.agent);
2148 let model_cooled = route
2149 .model
2150 .as_deref()
2151 .is_some_and(|model| is_model_in_cooldown(&route.agent, model));
2152 if agent_cooled || model_cooled {
2153 tracing::warn!(
2154 task_id,
2155 agent = route.agent,
2156 model = route.model.as_deref().unwrap_or(""),
2157 "routed agent/model entered cooldown after routing — deferring dispatch to next tick"
2158 );
2159 continue; }
2161 }
2162
2163 let session_name = tmux.session_name(repo, &task_id);
2165 if tmux
2166 .session_blocks_dispatch_from_map(&session_name, session_map)
2167 .await
2168 {
2169 tracing::warn!(
2170 task_id,
2171 session_name,
2172 "task has existing tmux session, skipping dispatch"
2173 );
2174 continue; }
2176
2177 let Ok(permit) = std::sync::Arc::clone(semaphore).try_acquire_owned() else {
2179 tracing::debug!("all parallel slots busy, skipping remaining tasks");
2180 break; };
2182
2183 let set_in_progress_result = task_manager
2185 .update_task_status(&task.id, Status::InProgress)
2186 .await;
2187 if let Err(e) = set_in_progress_result {
2188 tracing::error!(task_id, ?e, "failed to set in_progress, skipping dispatch");
2189 drop(permit);
2190 continue; }
2192 tracing::info!(task_id, "dispatching task");
2193
2194 capture
2196 .register_session(repo, &task_id, &session_name)
2197 .await;
2198
2199 let runner = std::sync::Arc::clone(runner);
2201 let backend = std::sync::Arc::clone(backend);
2202 let tmux = std::sync::Arc::clone(tmux);
2203 let capture = std::sync::Arc::clone(capture);
2204 let task_id_for_cleanup = task_id.clone();
2205 let task_owned = task;
2206 let weight_tx = weight_tx.clone();
2207 let repo_owned = repo.to_string();
2208 let task_manager_for_spawn = std::sync::Arc::clone(task_manager);
2209 let store_for_spawn = std::sync::Arc::clone(store);
2210
2211 let repo_ctx = repo_owned.clone();
2212 tokio::spawn(REPO_CONTEXT.scope(repo_ctx, async move {
2213 let _dispatch_guard = dispatch_guard; let dispatch_start = std::time::Instant::now();
2215 match runner
2216 .run_with_context(&task_owned, &backend, &tmux, route_result.as_ref())
2217 .await
2218 {
2219 Ok(signal) => {
2220 tracing::info!(task_id, "task runner completed");
2221
2222 let duration = dispatch_start.elapsed().as_secs_f64();
2223
2224 let enable_review = config::get("workflow.enable_review_agent")
2230 .map_or(true, |v| v != "false");
2231 let display_status = match &signal {
2232 WeightSignal::Success { .. } if enable_review => {
2233 let has_pr = match store_for_spawn
2237 .resolve_task_id(&repo_owned, &task_id)
2238 .await
2239 {
2240 Ok(Some(store_id)) => match store_for_spawn
2241 .get(store_id)
2242 .await
2243 {
2244 Ok(t) => t.pr_number.is_some(),
2245 Err(e) => {
2246 tracing::warn!(task_id, err = %e, "store.get() failed — defaulting to needs_review");
2247 true
2248 }
2249 },
2250 Ok(None) => false,
2251 Err(e) => {
2252 tracing::warn!(task_id, err = %e, "failed to check PR status — defaulting to needs_review");
2253 true
2254 }
2255 };
2256 if has_pr {
2257 "needs_review"
2258 } else {
2259 "done"
2260 }
2261 }
2262 WeightSignal::RateLimited { .. } | WeightSignal::Rerouted => "new",
2267 WeightSignal::Blocked => "blocked",
2268 WeightSignal::None if enable_review => "needs_review",
2273 WeightSignal::Success { .. } | WeightSignal::None => "done",
2274 };
2275
2276 let _ = weight_tx.send(signal).await;
2278
2279 if display_status == "needs_review" {
2283 if let Err(e) = task_manager_for_spawn
2286 .update_task_status_with_duration(
2287 &ExternalId(task_id.clone()),
2288 Status::NeedsReview,
2289 Some(duration),
2290 )
2291 .await
2292 {
2293 tracing::error!(task_id, err = %e, "update_task_status(NeedsReview) failed — task may be stuck");
2294 }
2295 } else {
2297 let final_status = match display_status {
2299 "done" => Status::Done,
2300 "blocked" => Status::Blocked,
2301 _ => Status::New,
2302 };
2303 if let Err(e) = task_manager_for_spawn
2304 .update_task_status_with_duration(
2305 &ExternalId(task_id.clone()),
2306 final_status,
2307 Some(duration),
2308 )
2309 .await
2310 {
2311 tracing::warn!(task_id, ?e, "failed to update task status after completion");
2312 }
2313 }
2314 }
2315 Err(e) => {
2316 tracing::error!(task_id, ?e, "task runner failed");
2317 let duration = dispatch_start.elapsed().as_secs_f64();
2318 if is_internal_id(&task_id) {
2319 if let Err(ue) = task_manager_for_spawn
2321 .update_task_status_with_duration(
2322 &ExternalId(task_id.clone()),
2323 Status::NeedsReview,
2324 Some(duration),
2325 )
2326 .await
2327 {
2328 tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
2329 }
2330 } else {
2331 if let Err(comment_err) = backend
2333 .post_comment(
2334 &ExternalId(task_id.clone()),
2335 &format!(
2336 "[{}] error: task runner failed: {e}{}",
2337 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
2338 crate::engine::orch_footer()
2339 ),
2340 )
2341 .await
2342 {
2343 tracing::warn!(
2344 task_id,
2345 ?comment_err,
2346 "failed to post error comment to GitHub"
2347 );
2348 }
2349 if let Err(ue) = task_manager_for_spawn
2353 .update_task_status_with_duration(
2354 &ExternalId(task_id.clone()),
2355 Status::NeedsReview,
2356 Some(duration),
2357 )
2358 .await
2359 {
2360 tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
2361 }
2362 }
2363 }
2364 }
2365
2366 capture.unregister_session(&repo_owned, &task_id_for_cleanup).await;
2368
2369 drop(permit);
2371 }));
2373 }
2374 Ok(())
2375}
2376
2377fn skips_agent(labels: &[String]) -> bool {
2378 labels
2379 .iter()
2380 .any(|label| label == "no-agent" || label == "owner:human")
2381}
2382
2383fn group_orphaned_routed_tasks(
2390 all_routed: Vec<crate::store::Task>,
2391 active_repos: &std::collections::HashSet<String>,
2392) -> std::collections::HashMap<String, Vec<crate::store::Task>> {
2393 let mut by_repo: std::collections::HashMap<String, Vec<crate::store::Task>> =
2394 std::collections::HashMap::new();
2395 for task in all_routed
2396 .into_iter()
2397 .filter(|t| !active_repos.contains(&t.repo))
2398 .filter(|t| !skips_agent(&t.labels))
2399 {
2400 by_repo.entry(task.repo.clone()).or_default().push(task);
2401 }
2402 by_repo
2403}
2404
2405#[expect(
2424 clippy::too_many_arguments,
2425 reason = "each argument is engine state the tick passes down"
2426)]
2427pub(crate) async fn dispatch_routed_tasks_global(
2428 tmux: &Arc<TmuxManager>,
2429 capture: &Arc<CaptureService>,
2430 semaphore: &Arc<Semaphore>,
2431 weight_tx: &mpsc::Sender<WeightSignal>,
2432 dispatch_mode: DispatchMode,
2433 dispatching: &Arc<DashMap<String, String>>,
2434 store: &Arc<TaskStore>,
2435 session_map: &std::collections::HashMap<String, bool>,
2436 active_repos: &std::collections::HashSet<String>,
2437) -> anyhow::Result<()> {
2438 use crate::store::TaskStatus;
2439
2440 let all_routed = store.list_all_by_status_global(TaskStatus::Routed).await?;
2441 let by_repo = group_orphaned_routed_tasks(all_routed, active_repos);
2442
2443 if by_repo.is_empty() {
2444 return Ok(());
2445 }
2446
2447 tracing::info!(
2448 repos = by_repo.len(),
2449 "global dispatch sweep: found routed tasks from inactive repos"
2450 );
2451
2452 for (repo, tasks) in by_repo {
2453 let backend: Arc<dyn ExternalBackend> = match crate::backends::github::GitHubBackend::new(
2454 repo.clone(),
2455 ) {
2456 Ok(b) => Arc::new(b),
2457 Err(e) => {
2458 tracing::warn!(repo = %repo, err = %e, "global sweep: failed to construct backend, skipping repo");
2459 continue;
2460 }
2461 };
2462 let task_manager = Arc::new(TaskManager::with_store(
2463 std::sync::Arc::clone(&backend),
2464 std::sync::Arc::clone(store),
2465 repo.clone(),
2466 ));
2467 let runner =
2468 Arc::new(TaskRunner::new(repo.clone()).with_store(std::sync::Arc::clone(store)));
2469 let dispatchable: Vec<ExternalTask> = tasks
2470 .iter()
2471 .map(crate::engine::tasks::store_task_to_external)
2472 .collect();
2473
2474 if let Err(e) = dispatch_tasks_for_repo(
2475 &backend,
2476 tmux,
2477 &repo,
2478 &runner,
2479 capture,
2480 semaphore,
2481 &task_manager,
2482 weight_tx,
2483 dispatch_mode,
2484 dispatching,
2485 store,
2486 session_map,
2487 dispatchable,
2488 )
2489 .await
2490 {
2491 tracing::warn!(repo = %repo, err = %e, "global sweep: dispatch failed for repo");
2492 }
2493 }
2494
2495 Ok(())
2496}
2497
2498pub(crate) async fn tick_unblock_parents(
2500 backend: &Arc<dyn ExternalBackend>,
2501 task_manager: &Arc<TaskManager>,
2502 store: &Arc<TaskStore>,
2503 repo: &str,
2504) -> anyhow::Result<()> {
2505 let blocked = task_manager.list_all_by_status(Status::Blocked).await?;
2506
2507 let store_blocked = store
2511 .list_by_status(repo, crate::store::TaskStatus::Blocked)
2512 .await?;
2513 let has_block_reason: std::collections::HashSet<String> = store_blocked
2514 .iter()
2515 .filter(|t| t.block_reason.is_some())
2516 .filter_map(|t| t.external_id.clone())
2517 .collect();
2518
2519 let handles: Vec<_> = blocked
2523 .into_iter()
2524 .filter(|task| {
2525 if has_block_reason.contains(&task.id.0) {
2526 tracing::debug!(
2527 task_id = task.id.0,
2528 "skipping blocked task with known block_reason"
2529 );
2530 false
2531 } else {
2532 true
2533 }
2534 })
2535 .map(|task| {
2536 let backend_clone = Arc::clone(backend);
2537 let task_manager_clone = Arc::clone(task_manager);
2538 let store_clone = Arc::clone(store);
2539 let repo_owned = repo.to_string();
2540 tokio::spawn(async move {
2541 let task_id = &task.id;
2542
2543 let mut children: Vec<ExternalId> = Vec::new();
2544 let mut child_statuses: std::collections::HashMap<
2545 String,
2546 crate::store::TaskStatus,
2547 > = std::collections::HashMap::new();
2548
2549 match store_clone.resolve_task_id(&repo_owned, &task_id.0).await {
2550 Ok(Some(parent_store_id)) => {
2551 match store_clone.list_children(&repo_owned, parent_store_id).await {
2552 Ok(store_children) => {
2553 for child in store_children {
2554 let Some(child_external_id) = child.external_id else {
2555 continue;
2556 };
2557 child_statuses.insert(child_external_id.clone(), child.status);
2558 children.push(ExternalId(child_external_id));
2559 }
2560 }
2561 Err(e) => {
2562 tracing::debug!(
2563 task_id = task_id.0,
2564 ?e,
2565 "failed to list store-linked children"
2566 );
2567 }
2568 }
2569 }
2570 Ok(None) => {}
2571 Err(e) => {
2572 tracing::debug!(
2573 task_id = task_id.0,
2574 ?e,
2575 "failed to resolve blocked task in store for child lookup"
2576 );
2577 }
2578 }
2579
2580 if !is_internal_id(&task_id.0) {
2581 match backend_clone.get_sub_issues(task_id).await {
2582 Ok(ids) => {
2583 let mut seen: std::collections::HashSet<String> =
2584 children.iter().map(|child| child.0.clone()).collect();
2585 for id in ids {
2586 if seen.insert(id.0.clone()) {
2587 children.push(id);
2588 }
2589 }
2590 }
2591 Err(e) => {
2592 tracing::debug!(task_id = task_id.0, ?e, "failed to get sub-issues");
2593 }
2594 }
2595 }
2596
2597 if children.is_empty() {
2599 return;
2600 }
2601
2602 let mut all_done = true;
2605 let child_ext_strs: Vec<String> =
2607 children.iter().map(|c| c.0.clone()).collect();
2608 let child_exts: Vec<&str> =
2609 child_ext_strs.iter().map(std::string::String::as_str).collect();
2610 match store_clone
2612 .get_statuses_by_external_ids(&repo_owned, &child_exts)
2613 .await
2614 { Ok(map) => {
2615 for (k, v) in map {
2616 child_statuses.insert(k, v);
2617 }
2618 } _ => {
2619 tracing::debug!(
2620 task_id = task_id.0,
2621 "failed to batch-query store for child statuses; falling back to per-child lookups"
2622 );
2623 }}
2624
2625 for child_id in &children {
2626 if let Some(status) = child_statuses.get(&child_id.0) {
2628 if *status != crate::store::TaskStatus::Done {
2629 all_done = false;
2630 break;
2631 }
2632 continue;
2633 }
2634
2635 match backend_clone.get_task(child_id).await {
2637 Ok(child) => {
2638 if !child.labels.iter().any(|l| l == Status::Done.as_label()) {
2639 all_done = false;
2640 break;
2641 }
2642 }
2643 Err(e) => {
2644 tracing::debug!(task_id = task_id.0, child = %child_id.0, ?e, "failed to fetch child task from backend — treating as not done");
2645 all_done = false;
2646 break;
2647 }
2648 }
2649 }
2650
2651 if all_done {
2652 tracing::info!(
2653 task_id = task_id.0,
2654 children = children.len(),
2655 "all children done, unblocking parent"
2656 );
2657 if let Err(e) =
2658 task_manager_clone.update_task_status(task_id, Status::New).await
2659 {
2660 tracing::warn!(task_id = task_id.0, ?e, "failed to unblock parent");
2661 }
2662 }
2663 })
2664 })
2665 .collect();
2666
2667 for handle in handles {
2668 if let Err(e) = handle.await {
2669 tracing::warn!(?e, "phase 4 sub-issue check panicked");
2670 }
2671 }
2672 Ok(())
2673}
2674
2675pub(crate) async fn tick_job_scheduler(
2677 jobs_path: &std::path::Path,
2678 backend: &Arc<dyn ExternalBackend>,
2679 store: Option<&Arc<TaskStore>>,
2680 repo: &str,
2681 transport: Option<&Arc<crate::channels::transport::Transport>>,
2682) -> anyhow::Result<()> {
2683 jobs::tick(jobs_path, backend, store, repo, transport).await
2684}
2685
2686#[expect(
2696 clippy::too_many_arguments,
2697 reason = "each argument is engine state the tick passes down"
2698)]
2699pub(crate) async fn tick_before_routing(
2700 backend: &Arc<dyn ExternalBackend>,
2701 tmux: &Arc<TmuxManager>,
2702 repo: &str,
2703 capture: &Arc<CaptureService>,
2704 config: &EngineConfig,
2705 task_manager: &Arc<TaskManager>,
2706 dispatching: &Arc<DashMap<String, String>>,
2707 store: &Arc<TaskStore>,
2708 session_map: &std::collections::HashMap<String, bool>,
2709) {
2710 let mut phases: Vec<(&str, u128)> = Vec::new();
2711 let mut at = Instant::now();
2712
2713 if let Err(e) = tick_check_session_completions(tmux, repo, capture, store).await {
2714 tracing::warn!(err = %e, "session completion phase failed");
2715 }
2716 phases.push(("sessions", at.elapsed().as_millis()));
2717
2718 if !store.is_leader() {
2721 super::log_phase_timings("tick.pre_routing", repo, &phases);
2722 return;
2723 }
2724
2725 at = Instant::now();
2726 if let Err(e) =
2727 tick_detect_silent_agents(tmux, repo, capture, backend, task_manager, config, store).await
2728 {
2729 tracing::warn!(err = %e, "silent-agent detection phase failed");
2730 }
2731 phases.push(("silent_agents", at.elapsed().as_millis()));
2732
2733 at = Instant::now();
2734 if let Err(e) = tick_recover_stuck_tasks(
2735 backend,
2736 tmux,
2737 repo,
2738 task_manager,
2739 config,
2740 store,
2741 session_map,
2742 dispatching,
2743 )
2744 .await
2745 {
2746 tracing::warn!(err = %e, "stuck-task recovery phase failed");
2747 }
2748 phases.push(("stuck_tasks", at.elapsed().as_millis()));
2749
2750 super::log_phase_timings("tick.pre_routing", repo, &phases);
2751}
2752
2753#[expect(
2761 clippy::too_many_arguments,
2762 reason = "each argument is engine state the tick passes down"
2763)]
2764pub(crate) async fn tick_after_routing(
2765 backend: &Arc<dyn ExternalBackend>,
2766 tmux: &Arc<TmuxManager>,
2767 repo: &str,
2768 runner: &Arc<TaskRunner>,
2769 capture: &Arc<CaptureService>,
2770 semaphore: &Arc<Semaphore>,
2771 project_dir: &std::path::Path,
2772 task_manager: &Arc<TaskManager>,
2773 weight_tx: &mpsc::Sender<WeightSignal>,
2774 dispatch_mode: DispatchMode,
2775 dispatching: &Arc<DashMap<String, String>>,
2776 store: &Arc<TaskStore>,
2777 transport: Option<&Arc<crate::channels::transport::Transport>>,
2778 session_map: &std::collections::HashMap<String, bool>,
2779) {
2780 let mut phases: Vec<(&str, u128)> = Vec::new();
2781 let mut at = Instant::now();
2782
2783 if let Err(e) = tick_dispatch_tasks(
2784 backend,
2785 tmux,
2786 repo,
2787 runner,
2788 capture,
2789 semaphore,
2790 task_manager,
2791 weight_tx,
2792 dispatch_mode,
2793 dispatching,
2794 store,
2795 session_map,
2796 )
2797 .await
2798 {
2799 tracing::warn!(err = %e, "dispatch phase failed");
2800 }
2801 phases.push(("dispatch", at.elapsed().as_millis()));
2802
2803 at = Instant::now();
2804 if let Err(e) = tick_unblock_parents(backend, task_manager, store, repo).await {
2805 tracing::warn!(err = %e, "parent-unblock phase failed");
2806 }
2807 phases.push(("unblock_parents", at.elapsed().as_millis()));
2808
2809 at = Instant::now();
2810 let jobs_path = project_dir.join(".orch.yml");
2811 if let Err(e) = tick_job_scheduler(&jobs_path, backend, Some(store), repo, transport).await {
2812 tracing::error!(?e, "job scheduler tick failed");
2813 }
2814 phases.push(("jobs", at.elapsed().as_millis()));
2815
2816 super::log_phase_timings("tick.post_routing", repo, &phases);
2817}
2818
2819#[expect(
2829 clippy::too_many_arguments,
2830 reason = "each argument is engine state the tick passes down"
2831)]
2832pub(crate) async fn tick_all(
2833 engines: &[ProjectEngine],
2834 tmux: &Arc<TmuxManager>,
2835 capture: &Arc<CaptureService>,
2836 semaphore: &Arc<Semaphore>,
2837 config: &EngineConfig,
2838 router: &Arc<tokio::sync::RwLock<Router>>,
2839 weight_tx: &mpsc::Sender<WeightSignal>,
2840 dispatching: &Arc<DashMap<String, String>>,
2841 transport: Option<&Arc<crate::channels::transport::Transport>>,
2842 active_repos: &std::collections::HashSet<String>,
2843 auto_merge_in_flight: &Arc<DashSet<String>>,
2844) {
2845 let _tick_span = tracing::info_span!("engine.tick").entered();
2846
2847 let Some(first) = engines.first() else {
2848 return;
2849 };
2850 let store = &first.store;
2853
2854 for (project, call_type, calls) in crate::github::http::drain_call_counts()
2856 .into_iter()
2857 .take(10)
2858 {
2859 tracing::debug!(
2860 project,
2861 call_type,
2862 calls,
2863 "github api calls since last tick"
2864 );
2865 }
2866
2867 let session_map = tmux.batch_session_active().await;
2869
2870 let mut passes = Vec::with_capacity(engines.len());
2871 for engine in engines {
2872 passes.push(REPO_CONTEXT.scope(
2873 engine.repo.clone(),
2874 tick_before_routing(
2875 &engine.backend,
2876 tmux,
2877 &engine.repo,
2878 capture,
2879 config,
2880 &engine.task_manager,
2881 dispatching,
2882 &engine.store,
2883 &session_map,
2884 ),
2885 ));
2886 }
2887 futures::future::join_all(passes).await;
2888
2889 if !store.is_leader() {
2890 return;
2891 }
2892
2893 let at = Instant::now();
2897 {
2898 let router_guard = router.read().await;
2899 tick_global_sweeps(
2900 &first.task_manager,
2901 store,
2902 &router_guard,
2903 dispatching,
2904 auto_merge_in_flight,
2905 )
2906 .await;
2907 }
2908 let sweeps_ms = at.elapsed().as_millis();
2909
2910 let at = Instant::now();
2911 recover_untracked_repo_stuck_tasks(
2912 tmux,
2913 config,
2914 store,
2915 &session_map,
2916 dispatching,
2917 active_repos,
2918 )
2919 .await;
2920 let untracked_ms = at.elapsed().as_millis();
2921
2922 let at = Instant::now();
2923 if let Err(err) = crate::engine::dependencies::reconcile_dependencies(
2924 first.backend.as_ref(),
2925 store,
2926 transport.map(std::convert::AsRef::as_ref),
2927 )
2928 .await
2929 {
2930 tracing::warn!(err = %err, "dependency reconcile failed");
2931 }
2932 super::log_phase_timings(
2933 "tick.global",
2934 &first.repo,
2935 &[
2936 ("global_sweeps", sweeps_ms),
2937 ("untracked_stuck", untracked_ms),
2938 ("dependencies", at.elapsed().as_millis()),
2939 ],
2940 );
2941
2942 {
2943 let mut router_guard = router.write().await;
2944 let mut routing_quota =
2945 RoutingTickQuota::new(crate::engine::router::config::max_tasks_per_routing_tick());
2946 for engine in engines {
2947 let at = Instant::now();
2948 REPO_CONTEXT
2949 .scope(engine.repo.clone(), async {
2950 if let Err(e) = tick_route_tasks(
2951 &engine.backend,
2952 &engine.task_manager,
2953 &mut router_guard,
2954 &engine.store,
2955 &engine.repo,
2956 &mut routing_quota,
2957 )
2958 .await
2959 {
2960 tracing::warn!(err = %e, "routing phase failed");
2961 }
2962 })
2963 .await;
2964 super::log_phase_timings(
2965 "tick.routing",
2966 &engine.repo,
2967 &[("route", at.elapsed().as_millis())],
2968 );
2969 }
2970
2971 if let Err(e) =
2975 route_new_tasks_global(&mut router_guard, store, active_repos, &mut routing_quota).await
2976 {
2977 tracing::warn!(err = %e, "global new-task routing sweep failed");
2978 }
2979 }
2980
2981 let dispatch_mode = dispatch_mode_from_router(&*router.read().await);
2982
2983 let session_map = tmux.batch_session_active().await;
2986
2987 let mut passes = Vec::with_capacity(engines.len());
2988 for engine in engines {
2989 passes.push(REPO_CONTEXT.scope(
2990 engine.repo.clone(),
2991 tick_after_routing(
2992 &engine.backend,
2993 tmux,
2994 &engine.repo,
2995 &engine.runner,
2996 capture,
2997 semaphore,
2998 &engine.project_dir,
2999 &engine.task_manager,
3000 weight_tx,
3001 dispatch_mode,
3002 dispatching,
3003 &engine.store,
3004 transport,
3005 &session_map,
3006 ),
3007 ));
3008 }
3009 futures::future::join_all(passes).await;
3010
3011 if let Err(e) = dispatch_routed_tasks_global(
3017 tmux,
3018 capture,
3019 semaphore,
3020 weight_tx,
3021 dispatch_mode,
3022 dispatching,
3023 store,
3024 &session_map,
3025 active_repos,
3026 )
3027 .await
3028 {
3029 tracing::warn!(err = %e, "global routed-task dispatch sweep failed");
3030 }
3031}
3032
3033#[cfg(test)]
3034mod tests {
3035 use super::*;
3036 use crate::backends::{ExternalId, ExternalTask, Mention, Status};
3037 use crate::channels::transport::Transport;
3038 use crate::engine::tasks::TaskManager;
3039 use crate::store::TaskStore;
3040 use crate::tmux::{SessionGuard, TmuxManager};
3041 use async_trait::async_trait;
3042 use std::sync::{Arc, Mutex};
3043
3044 #[test]
3045 fn skips_agent_for_human_and_no_agent_labels() {
3046 assert!(skips_agent(&["owner:human".to_string()]));
3047 assert!(skips_agent(&["no-agent".to_string()]));
3048 assert!(!skips_agent(&["status:new".to_string()]));
3049 }
3050
3051 fn cooled_review_router_for_test() -> Router {
3055 Router::new_for_test(
3056 crate::engine::router::RouterConfig::default(),
3057 vec!["claude".to_string()],
3058 )
3059 }
3060
3061 struct MockBackend {
3065 blocked_tasks: Vec<ExternalTask>,
3067 sub_issues: std::collections::HashMap<String, Vec<ExternalId>>,
3069 tasks_by_id: std::collections::HashMap<String, ExternalTask>,
3071 get_sub_issues_calls: Arc<Mutex<Vec<String>>>,
3073 status_updates: Arc<Mutex<Vec<(String, Status)>>>,
3075 removed_labels: Arc<Mutex<Vec<(String, String)>>>,
3077 posted_comments: Arc<Mutex<Vec<(String, String)>>>,
3079 }
3080
3081 impl MockBackend {
3082 fn new() -> Self {
3083 Self {
3084 blocked_tasks: vec![],
3085 sub_issues: std::collections::HashMap::new(),
3086 tasks_by_id: std::collections::HashMap::new(),
3087 get_sub_issues_calls: Arc::new(Mutex::new(vec![])),
3088 status_updates: Arc::new(Mutex::new(vec![])),
3089 removed_labels: Arc::new(Mutex::new(vec![])),
3090 posted_comments: Arc::new(Mutex::new(vec![])),
3091 }
3092 }
3093 }
3094
3095 fn make_task(id: &str, labels: &[&str]) -> ExternalTask {
3096 ExternalTask {
3097 id: ExternalId(id.to_string()),
3098 title: format!("Task {id}"),
3099 body: String::new(),
3100 state: "open".to_string(),
3101 labels: labels
3102 .iter()
3103 .map(std::string::ToString::to_string)
3104 .collect(),
3105 author: "bot".to_string(),
3106 created_at: "2026-01-01T00:00:00Z".to_string(),
3107 updated_at: "2026-01-01T00:00:00Z".to_string(),
3108 url: format!("https://github.com/test/test/issues/{id}"),
3109 }
3110 }
3111
3112 #[async_trait]
3113 impl ExternalBackend for MockBackend {
3114 fn name(&self) -> &'static str {
3115 "mock"
3116 }
3117 async fn create_task(
3118 &self,
3119 _title: &str,
3120 _body: &str,
3121 _labels: &[String],
3122 ) -> anyhow::Result<ExternalId> {
3123 Ok(ExternalId("new".to_string()))
3124 }
3125 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
3126 self.tasks_by_id
3127 .get(&id.0)
3128 .cloned()
3129 .ok_or_else(|| anyhow::anyhow!("task not found: {}", id.0))
3130 }
3131 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
3132 if status == Status::Blocked {
3133 Ok(self.blocked_tasks.clone())
3134 } else {
3135 Ok(vec![])
3136 }
3137 }
3138 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
3139 Ok(vec![])
3140 }
3141 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
3142 self.posted_comments
3143 .lock()
3144 .unwrap()
3145 .push((_id.0.clone(), _body.to_string()));
3146 Ok(())
3147 }
3148 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
3149 Ok(())
3150 }
3151 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
3152 self.removed_labels
3153 .lock()
3154 .unwrap()
3155 .push((_id.0.clone(), _label.to_string()));
3156 Ok(())
3157 }
3158 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
3159 self.get_sub_issues_calls.lock().unwrap().push(id.0.clone());
3160 Ok(self.sub_issues.get(&id.0).cloned().unwrap_or_default())
3161 }
3162 async fn create_sub_task(
3163 &self,
3164 _parent: &ExternalId,
3165 _title: &str,
3166 _body: &str,
3167 _labels: &[String],
3168 ) -> anyhow::Result<ExternalId> {
3169 Ok(ExternalId("child".to_string()))
3170 }
3171 async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
3172 Ok(())
3173 }
3174 async fn has_open_issue_with_title(
3175 &self,
3176 _title: &str,
3177 _label: &str,
3178 ) -> anyhow::Result<bool> {
3179 Ok(false)
3180 }
3181 async fn health_check(&self) -> anyhow::Result<()> {
3182 Ok(())
3183 }
3184 async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
3185 Ok(false)
3186 }
3187 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
3188 Ok(Some("testbot".to_string()))
3189 }
3190 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
3191 Ok(vec![])
3192 }
3193 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
3194 self.status_updates
3195 .lock()
3196 .unwrap()
3197 .push((id.0.clone(), status));
3198 Ok(())
3199 }
3200 }
3201
3202 fn make_task_manager(backend: Arc<dyn ExternalBackend>) -> Arc<TaskManager> {
3203 Arc::new(TaskManager::new(backend))
3204 }
3205
3206 #[tokio::test]
3209 async fn unblock_parents_unblocks_when_all_children_done() {
3210 let mut mock = MockBackend::new();
3211
3212 let parent = make_task("10", &["status:blocked"]);
3214 mock.blocked_tasks.push(parent.clone());
3215 mock.sub_issues.insert(
3216 "10".to_string(),
3217 vec![ExternalId("11".to_string()), ExternalId("12".to_string())],
3218 );
3219 mock.tasks_by_id
3220 .insert("11".to_string(), make_task("11", &["status:done"]));
3221 mock.tasks_by_id
3222 .insert("12".to_string(), make_task("12", &["status:done"]));
3223
3224 let status_updates = std::sync::Arc::clone(&mock.status_updates);
3225 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3226 let task_manager = make_task_manager(std::sync::Arc::clone(&backend));
3227 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3228
3229 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3230 .await
3231 .unwrap();
3232
3233 let updates = status_updates.lock().unwrap();
3234 assert_eq!(updates.len(), 1, "parent should be unblocked");
3235 assert_eq!(updates[0], ("10".to_string(), Status::New));
3236 }
3237
3238 #[tokio::test]
3239 async fn unblock_parents_skips_when_child_not_done() {
3240 let mut mock = MockBackend::new();
3241
3242 let parent = make_task("20", &["status:blocked"]);
3243 mock.blocked_tasks.push(parent.clone());
3244 mock.sub_issues.insert(
3245 "20".to_string(),
3246 vec![ExternalId("21".to_string()), ExternalId("22".to_string())],
3247 );
3248 mock.tasks_by_id
3250 .insert("21".to_string(), make_task("21", &["status:done"]));
3251 mock.tasks_by_id
3252 .insert("22".to_string(), make_task("22", &["status:in_progress"]));
3253
3254 let status_updates = std::sync::Arc::clone(&mock.status_updates);
3255 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3256 let task_manager = make_task_manager(std::sync::Arc::clone(&backend));
3257 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3258
3259 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3260 .await
3261 .unwrap();
3262
3263 let updates = status_updates.lock().unwrap();
3264 assert!(
3265 updates.is_empty(),
3266 "should not unblock when a child is still running"
3267 );
3268 }
3269
3270 #[tokio::test]
3271 async fn unblock_parents_skips_task_with_no_children() {
3272 let mut mock = MockBackend::new();
3273
3274 let parent = make_task("30", &["status:blocked"]);
3276 mock.blocked_tasks.push(parent.clone());
3277 let status_updates = std::sync::Arc::clone(&mock.status_updates);
3280 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3281 let task_manager = make_task_manager(std::sync::Arc::clone(&backend));
3282 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3283
3284 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3285 .await
3286 .unwrap();
3287
3288 let updates = status_updates.lock().unwrap();
3289 assert!(
3290 updates.is_empty(),
3291 "should not unblock tasks with no sub-issues"
3292 );
3293 }
3294
3295 #[tokio::test]
3296 async fn unblock_parents_skips_task_with_block_reason_in_store() {
3297 let mut mock = MockBackend::new();
3300
3301 let parent = make_task("60", &["status:blocked"]);
3303 mock.blocked_tasks.push(parent.clone());
3304 mock.sub_issues
3307 .insert("60".to_string(), vec![ExternalId("61".to_string())]);
3308 mock.tasks_by_id
3309 .insert("61".to_string(), make_task("61", &["status:done"]));
3310
3311 let status_updates = std::sync::Arc::clone(&mock.status_updates);
3312 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3313 let task_manager = make_task_manager(std::sync::Arc::clone(&backend));
3314
3315 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3317 let task_id = store
3318 .upsert_external(&crate::store::UpsertExternal {
3319 repo: "test/repo",
3320 ext_id: "60",
3321 title: "Task 60",
3322 body: "",
3323 author: "bot",
3324 url: "https://github.com/test/test/issues/60",
3325 labels: &[],
3326 origin: "github",
3327 })
3328 .await
3329 .unwrap();
3330 store
3331 .update_status(task_id, crate::store::TaskStatus::Blocked)
3332 .await
3333 .unwrap();
3334 store
3335 .set_block_reason(task_id, Some("CI failure limit reached"))
3336 .await
3337 .unwrap();
3338
3339 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3340 .await
3341 .unwrap();
3342
3343 let updates = status_updates.lock().unwrap();
3344 assert!(
3345 updates.is_empty(),
3346 "task with block_reason should be skipped — no API call, no unblock"
3347 );
3348 }
3349
3350 #[tokio::test]
3351 async fn unblock_parents_unblocks_internal_parent_from_store_children() {
3352 let mock = MockBackend::new();
3353 let get_sub_issues_calls = std::sync::Arc::clone(&mock.get_sub_issues_calls);
3354 let status_updates = std::sync::Arc::clone(&mock.status_updates);
3355 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3356 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3357 let task_manager = Arc::new(TaskManager::with_store(
3358 std::sync::Arc::clone(&backend),
3359 std::sync::Arc::clone(&store),
3360 "test/repo".to_string(),
3361 ));
3362
3363 let parent_id = store
3364 .create_internal("test/repo", "Parent", "", "job", "daily", None)
3365 .await
3366 .unwrap();
3367 store
3368 .update_status(parent_id, crate::store::TaskStatus::Blocked)
3369 .await
3370 .unwrap();
3371 let child_id = store
3372 .create(&crate::store::NewTask {
3373 external_id: Some("91".to_string()),
3374 repo: "test/repo".to_string(),
3375 origin: "github".to_string(),
3376 title: "Child".to_string(),
3377 body: String::new(),
3378 source: String::new(),
3379 source_id: String::new(),
3380 author: String::new(),
3381 url: String::new(),
3382 labels: vec![],
3383 parent_id: Some(parent_id),
3384 })
3385 .await
3386 .unwrap();
3387 store
3388 .update_status(child_id, crate::store::TaskStatus::Done)
3389 .await
3390 .unwrap();
3391
3392 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3393 .await
3394 .unwrap();
3395
3396 let parent = store.get(parent_id).await.unwrap();
3397 assert_eq!(parent.status, crate::store::TaskStatus::New);
3398 assert!(
3399 status_updates.lock().unwrap().is_empty(),
3400 "internal parent should be updated in store without backend mirroring"
3401 );
3402 assert!(
3403 get_sub_issues_calls.lock().unwrap().is_empty(),
3404 "internal parent should not query backend sub-issues"
3405 );
3406 }
3407
3408 #[cfg(test)]
3412 async fn set_task_updated_at_past(store: &TaskStore, task_id: i64) {
3413 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
3414 .bind(task_id)
3415 .execute(store.pool())
3416 .await
3417 .unwrap();
3418 }
3419
3420 async fn set_in_review_entered_at_past(store: &TaskStore, task_id: i64) {
3422 sqlx::query(
3423 "UPDATE task_activity SET timestamp = '2020-01-01T00:00:00Z'
3424 WHERE task_id = ? AND to_status = 'in_review'",
3425 )
3426 .bind(task_id)
3427 .execute(store.pool())
3428 .await
3429 .unwrap();
3430 }
3431
3432 #[tokio::test]
3433 async fn recover_stuck_tasks_resets_external_in_review_to_needs_review() {
3434 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3435 let mock = MockBackend::new();
3436 let status_updates = std::sync::Arc::clone(&mock.status_updates);
3437 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3438 let task_manager = Arc::new(TaskManager::with_store(
3439 std::sync::Arc::clone(&backend),
3440 std::sync::Arc::clone(&store),
3441 "owner/repo".to_string(),
3442 ));
3443 let tmux = Arc::new(TmuxManager::new());
3444 let config = EngineConfig {
3445 no_session_stuck_timeout: 600,
3446 stuck_timeout: 1800,
3447 ..EngineConfig::default()
3448 };
3449
3450 let id = store
3452 .upsert_external(&crate::store::UpsertExternal {
3453 repo: "owner/repo",
3454 ext_id: "99",
3455 title: "Stuck InReview",
3456 body: "",
3457 author: "",
3458 url: "",
3459 labels: &[],
3460 origin: "github",
3461 })
3462 .await
3463 .unwrap();
3464 store
3465 .update_status(id, crate::store::TaskStatus::InReview)
3466 .await
3467 .unwrap();
3468 set_in_review_entered_at_past(&store, id).await;
3469 set_task_updated_at_past(&store, id).await;
3470
3471 tick_recover_stuck_tasks(
3472 &backend,
3473 &tmux,
3474 "owner/repo",
3475 &task_manager,
3476 &config,
3477 &store,
3478 &std::collections::HashMap::new(),
3479 &Arc::new(DashMap::new()),
3480 )
3481 .await
3482 .unwrap();
3483
3484 let updates = status_updates.lock().unwrap();
3485 assert_eq!(updates.len(), 1, "stuck InReview task should be recovered");
3486 assert_eq!(updates[0].0, "99");
3487 assert_eq!(updates[0].1, Status::NeedsReview);
3488 }
3489
3490 #[tokio::test]
3491 async fn recover_stuck_tasks_skips_recent_in_review() {
3492 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3493 let mock = MockBackend::new();
3494 let status_updates = std::sync::Arc::clone(&mock.status_updates);
3495 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3496 let task_manager = Arc::new(TaskManager::with_store(
3497 std::sync::Arc::clone(&backend),
3498 std::sync::Arc::clone(&store),
3499 "owner/repo".to_string(),
3500 ));
3501 let tmux = Arc::new(TmuxManager::new());
3502 let config = EngineConfig {
3503 no_session_stuck_timeout: 600, stuck_timeout: 1800,
3505 ..EngineConfig::default()
3506 };
3507
3508 let id = store
3510 .upsert_external(&crate::store::UpsertExternal {
3511 repo: "owner/repo",
3512 ext_id: "100",
3513 title: "Recent InReview",
3514 body: "",
3515 author: "",
3516 url: "",
3517 labels: &[],
3518 origin: "github",
3519 })
3520 .await
3521 .unwrap();
3522 store
3523 .update_status(id, crate::store::TaskStatus::InReview)
3524 .await
3525 .unwrap();
3526 set_task_updated_at_past(&store, id).await;
3527
3528 tick_recover_stuck_tasks(
3529 &backend,
3530 &tmux,
3531 "owner/repo",
3532 &task_manager,
3533 &config,
3534 &store,
3535 &std::collections::HashMap::new(),
3536 &Arc::new(DashMap::new()),
3537 )
3538 .await
3539 .unwrap();
3540
3541 let updates = status_updates.lock().unwrap();
3542 assert!(
3543 updates.is_empty(),
3544 "recent InReview task should not be recovered"
3545 );
3546 }
3547
3548 #[tokio::test]
3549 async fn recover_stuck_tasks_resets_internal_in_review_to_needs_review() {
3550 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3551 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3552 let task_manager = Arc::new(TaskManager::with_store(
3553 std::sync::Arc::clone(&backend),
3554 std::sync::Arc::clone(&store),
3555 "owner/repo".to_string(),
3556 ));
3557 let tmux = Arc::new(TmuxManager::new());
3558 let config = EngineConfig {
3559 no_session_stuck_timeout: 600,
3560 stuck_timeout: 1800,
3561 ..EngineConfig::default()
3562 };
3563
3564 let id = store
3566 .create_internal("owner/repo", "Internal InReview", "", "cron", "1", None)
3567 .await
3568 .unwrap();
3569 store
3570 .update_status(id, crate::store::TaskStatus::InReview)
3571 .await
3572 .unwrap();
3573 set_in_review_entered_at_past(&store, id).await;
3574 set_task_updated_at_past(&store, id).await;
3575
3576 tick_recover_stuck_tasks(
3577 &backend,
3578 &tmux,
3579 "owner/repo",
3580 &task_manager,
3581 &config,
3582 &store,
3583 &std::collections::HashMap::new(),
3584 &Arc::new(DashMap::new()),
3585 )
3586 .await
3587 .unwrap();
3588
3589 let task = store.get(id).await.unwrap();
3590 assert_eq!(
3591 task.status,
3592 crate::store::TaskStatus::NeedsReview,
3593 "stuck internal InReview task should be reset to NeedsReview"
3594 );
3595 }
3596
3597 #[tokio::test]
3598 async fn recover_stuck_tasks_ages_in_review_from_status_entry_not_updated_at() {
3599 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3600 let mock = MockBackend::new();
3601 let status_updates = std::sync::Arc::clone(&mock.status_updates);
3602 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3603 let task_manager = Arc::new(TaskManager::with_store(
3604 std::sync::Arc::clone(&backend),
3605 std::sync::Arc::clone(&store),
3606 "owner/repo".to_string(),
3607 ));
3608 let tmux = Arc::new(TmuxManager::new());
3609 let config = EngineConfig {
3610 no_session_stuck_timeout: 600,
3611 stuck_timeout: 1800,
3612 ..EngineConfig::default()
3613 };
3614
3615 let id = store
3616 .upsert_external(&crate::store::UpsertExternal {
3617 repo: "owner/repo",
3618 ext_id: "102",
3619 title: "Fresh updated_at InReview",
3620 body: "",
3621 author: "",
3622 url: "",
3623 labels: &[],
3624 origin: "github",
3625 })
3626 .await
3627 .unwrap();
3628 store
3629 .update_status(id, crate::store::TaskStatus::InReview)
3630 .await
3631 .unwrap();
3632 set_in_review_entered_at_past(&store, id).await;
3633 store
3634 .set_fields(id, &[("pr_number", serde_json::json!(42))])
3635 .await
3636 .unwrap();
3637
3638 tick_recover_stuck_tasks(
3639 &backend,
3640 &tmux,
3641 "owner/repo",
3642 &task_manager,
3643 &config,
3644 &store,
3645 &std::collections::HashMap::new(),
3646 &Arc::new(DashMap::new()),
3647 )
3648 .await
3649 .unwrap();
3650
3651 let updates = status_updates.lock().unwrap();
3652 assert_eq!(
3653 updates.len(),
3654 1,
3655 "in_review stuck recovery must age from the status entry even when updated_at is fresh"
3656 );
3657 assert_eq!(updates[0].0, "102");
3658 assert_eq!(updates[0].1, Status::NeedsReview);
3659 }
3660
3661 async fn orphaned_review_status_after_recovery(
3664 with_decision: bool,
3665 ) -> crate::store::TaskStatus {
3666 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3667 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3668 let task_manager = Arc::new(TaskManager::with_store(
3669 std::sync::Arc::clone(&backend),
3670 std::sync::Arc::clone(&store),
3671 "owner/repo".to_string(),
3672 ));
3673 let tmux = Arc::new(TmuxManager::new());
3674
3675 let id = store
3676 .upsert_external(&crate::store::UpsertExternal {
3677 repo: "owner/repo",
3678 ext_id: "55",
3679 title: "Orphaned review",
3680 body: "",
3681 author: "",
3682 url: "",
3683 labels: &[],
3684 origin: "github",
3685 })
3686 .await
3687 .unwrap();
3688 store
3689 .update_status(id, crate::store::TaskStatus::InReview)
3690 .await
3691 .unwrap();
3692 set_in_review_entered_at_past(&store, id).await;
3694 if with_decision {
3695 crate::store::store_log_activity(
3696 &Some(std::sync::Arc::clone(&store)),
3697 "owner/repo",
3698 "55",
3699 "review_decision",
3700 None,
3701 None,
3702 None::<&str>,
3703 None::<&str>,
3704 Some(&serde_json::json!({"decision": "approve"})),
3705 )
3706 .await;
3707 }
3708
3709 tick_recover_stuck_tasks(
3710 &backend,
3711 &tmux,
3712 "owner/repo",
3713 &task_manager,
3714 &EngineConfig::default(),
3715 &store,
3716 &std::collections::HashMap::new(),
3717 &Arc::new(DashMap::new()),
3718 )
3719 .await
3720 .unwrap();
3721
3722 store.get(id).await.unwrap().status
3723 }
3724
3725 #[tokio::test]
3726 async fn stale_in_review_recovery_resets_orphaned_task_without_session() {
3727 assert_eq!(
3728 orphaned_review_status_after_recovery(false).await,
3729 crate::store::TaskStatus::NeedsReview
3730 );
3731 }
3732
3733 #[tokio::test]
3734 async fn stale_in_review_recovery_leaves_decided_task_waiting_on_merge() {
3735 assert_eq!(
3736 orphaned_review_status_after_recovery(true).await,
3737 crate::store::TaskStatus::InReview
3738 );
3739 }
3740
3741 #[tokio::test]
3742 async fn recover_stuck_tasks_resets_review_agent_failures() {
3743 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3744 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3745 let task_manager = Arc::new(TaskManager::with_store(
3746 std::sync::Arc::clone(&backend),
3747 std::sync::Arc::clone(&store),
3748 "owner/repo".to_string(),
3749 ));
3750 let tmux = Arc::new(TmuxManager::new());
3751
3752 let id = store
3753 .upsert_external(&crate::store::UpsertExternal {
3754 repo: "owner/repo",
3755 ext_id: "104",
3756 title: "Orphaned review with failures",
3757 body: "",
3758 author: "",
3759 url: "",
3760 labels: &[],
3761 origin: "github",
3762 })
3763 .await
3764 .unwrap();
3765 store
3766 .update_status(id, crate::store::TaskStatus::InReview)
3767 .await
3768 .unwrap();
3769 store
3770 .set_fields(id, &[("review_agent_failures", serde_json::json!(2))])
3771 .await
3772 .unwrap();
3773 set_in_review_entered_at_past(&store, id).await;
3774
3775 tick_recover_stuck_tasks(
3776 &backend,
3777 &tmux,
3778 "owner/repo",
3779 &task_manager,
3780 &EngineConfig::default(),
3781 &store,
3782 &std::collections::HashMap::new(),
3783 &Arc::new(DashMap::new()),
3784 )
3785 .await
3786 .unwrap();
3787
3788 let task = store.get(id).await.unwrap();
3789 assert_eq!(task.status, crate::store::TaskStatus::NeedsReview);
3790 assert_eq!(
3791 task.review_agent_failures, 0,
3792 "a dead review session is an infrastructure event, not an agent failure"
3793 );
3794 }
3795
3796 #[tokio::test]
3797 async fn recover_stuck_tasks_reclaims_hung_in_review_session() {
3798 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3799 let mock = MockBackend::new();
3800 let status_updates = std::sync::Arc::clone(&mock.status_updates);
3801 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3802 let task_manager = Arc::new(TaskManager::with_store(
3803 std::sync::Arc::clone(&backend),
3804 std::sync::Arc::clone(&store),
3805 "owner/repo".to_string(),
3806 ));
3807 let tmux = Arc::new(TmuxManager::new());
3808 let config = EngineConfig {
3809 no_session_stuck_timeout: 600,
3810 stuck_timeout: 1800,
3811 ..EngineConfig::default()
3812 };
3813
3814 let id = store
3815 .upsert_external(&crate::store::UpsertExternal {
3816 repo: "owner/repo",
3817 ext_id: "103",
3818 title: "Hung review session",
3819 body: "",
3820 author: "",
3821 url: "",
3822 labels: &[],
3823 origin: "github",
3824 })
3825 .await
3826 .unwrap();
3827 store
3828 .update_status(id, crate::store::TaskStatus::InReview)
3829 .await
3830 .unwrap();
3831 set_in_review_entered_at_past(&store, id).await;
3832 store
3833 .set_fields(id, &[("pr_number", serde_json::json!(43))])
3834 .await
3835 .unwrap();
3836
3837 let session_name = tmux.session_name("owner/repo", "103-review");
3838 let mut session_map = std::collections::HashMap::new();
3839 session_map.insert(session_name, true);
3840
3841 tick_recover_stuck_tasks(
3842 &backend,
3843 &tmux,
3844 "owner/repo",
3845 &task_manager,
3846 &config,
3847 &store,
3848 &session_map,
3849 &Arc::new(DashMap::new()),
3850 )
3851 .await
3852 .unwrap();
3853
3854 let updates = status_updates.lock().unwrap();
3855 assert_eq!(
3856 updates.len(),
3857 1,
3858 "a live hung review session must be reclaimed once the in_review entry ages past stuck_timeout"
3859 );
3860 assert_eq!(updates[0].0, "103");
3861 assert_eq!(updates[0].1, Status::NeedsReview);
3862 }
3863
3864 #[tokio::test]
3865 async fn untracked_repo_recovery_resets_stale_in_progress_to_new() {
3866 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3867 let tmux = Arc::new(TmuxManager::new());
3868 let config = EngineConfig {
3869 no_session_stuck_timeout: 600,
3870 stuck_timeout: 1800,
3871 ..EngineConfig::default()
3872 };
3873
3874 let id = store
3876 .upsert_external(&crate::store::UpsertExternal {
3877 repo: "owner/other",
3878 ext_id: "777",
3879 title: "Cross-repo stuck task",
3880 body: "",
3881 author: "",
3882 url: "",
3883 labels: &[],
3884 origin: "github",
3885 })
3886 .await
3887 .unwrap();
3888 store
3889 .update_status(id, crate::store::TaskStatus::InProgress)
3890 .await
3891 .unwrap();
3892 set_task_updated_at_past(&store, id).await;
3893
3894 let active = std::collections::HashSet::from(["owner/repo".to_string()]);
3895 recover_untracked_repo_stuck_tasks(
3896 &tmux,
3897 &config,
3898 &store,
3899 &std::collections::HashMap::new(),
3900 &Arc::new(DashMap::new()),
3901 &active,
3902 )
3903 .await;
3904
3905 let task = store.get(id).await.unwrap();
3906 assert_eq!(
3907 task.status,
3908 crate::store::TaskStatus::New,
3909 "cross-repo stale in_progress task should be reset to New"
3910 );
3911 assert_eq!(task.route_attempts, 0, "route attempts should be cleared");
3912 assert!(task.agent.is_none(), "agent should be cleared");
3913 assert!(task.model.is_none(), "model should be cleared");
3914 }
3915
3916 #[tokio::test]
3917 async fn untracked_repo_recovery_leaves_active_repos_to_their_own_pass() {
3918 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3919 let tmux = Arc::new(TmuxManager::new());
3920 let config = EngineConfig {
3921 no_session_stuck_timeout: 600,
3922 stuck_timeout: 1800,
3923 ..EngineConfig::default()
3924 };
3925 let id = store
3926 .upsert_external(&crate::store::UpsertExternal {
3927 repo: "owner/other",
3928 ext_id: "778",
3929 title: "Stuck task in an active repo",
3930 body: "",
3931 author: "",
3932 url: "",
3933 labels: &[],
3934 origin: "github",
3935 })
3936 .await
3937 .unwrap();
3938 store
3939 .update_status(id, crate::store::TaskStatus::InProgress)
3940 .await
3941 .unwrap();
3942 set_task_updated_at_past(&store, id).await;
3943
3944 let active =
3946 std::collections::HashSet::from(["owner/repo".to_string(), "owner/other".to_string()]);
3947 recover_untracked_repo_stuck_tasks(
3948 &tmux,
3949 &config,
3950 &store,
3951 &std::collections::HashMap::new(),
3952 &Arc::new(DashMap::new()),
3953 &active,
3954 )
3955 .await;
3956
3957 assert_eq!(
3958 store.get(id).await.unwrap().status,
3959 crate::store::TaskStatus::InProgress
3960 );
3961 }
3962
3963 #[tokio::test]
3964 async fn recover_stuck_tasks_finalizes_open_task_runs_rows() {
3965 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3966 let mock = MockBackend::new();
3967 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3968 let task_manager = Arc::new(TaskManager::with_store(
3969 std::sync::Arc::clone(&backend),
3970 std::sync::Arc::clone(&store),
3971 "owner/repo".to_string(),
3972 ));
3973 let tmux = Arc::new(TmuxManager::new());
3974 let config = EngineConfig {
3975 no_session_stuck_timeout: 600,
3976 stuck_timeout: 1800,
3977 ..EngineConfig::default()
3978 };
3979
3980 let id = store
3981 .create_internal("owner/repo", "Stuck with open run", "", "cron", "1", None)
3982 .await
3983 .unwrap();
3984 store
3985 .set_fields(
3986 id,
3987 &[
3988 ("agent", serde_json::json!("claude")),
3989 ("model", serde_json::json!("sonnet")),
3990 ("route_attempts", serde_json::json!(1)),
3991 ],
3992 )
3993 .await
3994 .unwrap();
3995 store
3996 .update_status(id, crate::store::TaskStatus::InProgress)
3997 .await
3998 .unwrap();
3999 set_task_updated_at_past(&store, id).await;
4000
4001 store
4003 .start_run(&crate::store::StartRun {
4004 task_id: id,
4005 attempt: 1,
4006 run_type: "agent",
4007 agent: "claude",
4008 model: "sonnet",
4009 command: "claude --model sonnet",
4010 prompt: "fix it",
4011 })
4012 .await
4013 .unwrap();
4014
4015 tick_recover_stuck_tasks(
4016 &backend,
4017 &tmux,
4018 "owner/repo",
4019 &task_manager,
4020 &config,
4021 &store,
4022 &std::collections::HashMap::new(),
4023 &Arc::new(DashMap::new()),
4024 )
4025 .await
4026 .unwrap();
4027
4028 let task = store.get(id).await.unwrap();
4029 assert_eq!(task.status, crate::store::TaskStatus::New);
4030
4031 let runs = store.get_runs(id).await.unwrap();
4032 assert_eq!(runs.len(), 1);
4033 assert_eq!(runs[0].outcome, "aborted");
4034 assert!(runs[0].completed_at.is_some());
4035 assert_eq!(
4036 runs[0].error,
4037 "stuck-task recovery: internal no session found"
4038 );
4039 }
4040
4041 #[tokio::test]
4058 async fn recover_stuck_tasks_skips_reclaim_while_dispatch_guard_held() {
4059 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4060 let mock = MockBackend::new();
4061 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4062 let task_manager = Arc::new(TaskManager::with_store(
4063 std::sync::Arc::clone(&backend),
4064 std::sync::Arc::clone(&store),
4065 "owner/repo".to_string(),
4066 ));
4067 let tmux = Arc::new(TmuxManager::new());
4068 let config = EngineConfig {
4069 no_session_stuck_timeout: 600,
4070 stuck_timeout: 1800,
4071 ..EngineConfig::default()
4072 };
4073
4074 let id = store
4075 .create_internal(
4076 "owner/repo",
4077 "Long-running daily job",
4078 "",
4079 "cron",
4080 "1",
4081 None,
4082 )
4083 .await
4084 .unwrap();
4085 store
4086 .set_fields(
4087 id,
4088 &[
4089 ("agent", serde_json::json!("claude")),
4090 ("model", serde_json::json!("sonnet")),
4091 ("route_attempts", serde_json::json!(1)),
4092 ],
4093 )
4094 .await
4095 .unwrap();
4096 store
4097 .update_status(id, crate::store::TaskStatus::InProgress)
4098 .await
4099 .unwrap();
4100 set_task_updated_at_past(&store, id).await;
4103
4104 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4107 dispatching.insert(
4108 format!("owner/repo/internal:{id}"),
4109 format!("internal:{id}"),
4110 );
4111
4112 tick_recover_stuck_tasks(
4113 &backend,
4114 &tmux,
4115 "owner/repo",
4116 &task_manager,
4117 &config,
4118 &store,
4119 &std::collections::HashMap::new(),
4120 &dispatching,
4121 )
4122 .await
4123 .unwrap();
4124
4125 let task = store.get(id).await.unwrap();
4126 assert_eq!(
4127 task.status,
4128 crate::store::TaskStatus::InProgress,
4129 "task should not be reclaimed while its dispatch guard is still held"
4130 );
4131
4132 let runs = store.get_runs(id).await.unwrap();
4134 assert!(runs.is_empty());
4135
4136 dispatching.clear();
4139 tick_recover_stuck_tasks(
4140 &backend,
4141 &tmux,
4142 "owner/repo",
4143 &task_manager,
4144 &config,
4145 &store,
4146 &std::collections::HashMap::new(),
4147 &dispatching,
4148 )
4149 .await
4150 .unwrap();
4151 let task = store.get(id).await.unwrap();
4152 assert_eq!(
4153 task.status,
4154 crate::store::TaskStatus::New,
4155 "task should still be reclaimed once the dispatch guard is released"
4156 );
4157 }
4158
4159 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
4175 async fn recover_stuck_tasks_never_reclaims_while_guard_held_under_real_concurrency() {
4176 const HAMMER_ITERATIONS: u32 = 300;
4177
4178 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4179 let mock = MockBackend::new();
4180 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4181 let task_manager = Arc::new(TaskManager::with_store(
4182 std::sync::Arc::clone(&backend),
4183 std::sync::Arc::clone(&store),
4184 "owner/repo".to_string(),
4185 ));
4186 let tmux = Arc::new(TmuxManager::new());
4187 let config = EngineConfig {
4188 no_session_stuck_timeout: 600,
4189 stuck_timeout: 1800,
4190 ..EngineConfig::default()
4191 };
4192
4193 let id = store
4194 .create_internal(
4195 "owner/repo",
4196 "Long-running daily job",
4197 "",
4198 "cron",
4199 "1",
4200 None,
4201 )
4202 .await
4203 .unwrap();
4204 store
4205 .set_fields(
4206 id,
4207 &[
4208 ("agent", serde_json::json!("claude")),
4209 ("model", serde_json::json!("sonnet")),
4210 ("route_attempts", serde_json::json!(1)),
4211 ],
4212 )
4213 .await
4214 .unwrap();
4215 store
4216 .update_status(id, crate::store::TaskStatus::InProgress)
4217 .await
4218 .unwrap();
4219 set_task_updated_at_past(&store, id).await;
4220
4221 let dispatch_key = format!("owner/repo/internal:{id}");
4222 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4223
4224 let claim_task_id = format!("internal:{id}");
4231 {
4232 use dashmap::mapref::entry::Entry;
4233 match dispatching.entry(dispatch_key.clone()) {
4234 Entry::Vacant(slot) => {
4235 slot.insert(claim_task_id);
4236 }
4237 Entry::Occupied(_) => panic!("dispatch key already claimed"),
4238 }
4239 }
4240 let guard = DispatchGuard::new(Arc::clone(&dispatching), dispatch_key.clone());
4241
4242 let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
4253 let holder = tokio::spawn(async move {
4254 let _ = release_rx.await;
4255 drop(guard);
4256 });
4257
4258 for i in 0..HAMMER_ITERATIONS {
4266 tick_recover_stuck_tasks(
4267 &backend,
4268 &tmux,
4269 "owner/repo",
4270 &task_manager,
4271 &config,
4272 &store,
4273 &std::collections::HashMap::new(),
4274 &dispatching,
4275 )
4276 .await
4277 .unwrap();
4278
4279 let task = store.get(id).await.unwrap();
4280 assert_eq!(
4281 task.status,
4282 crate::store::TaskStatus::InProgress,
4283 "task must never be reclaimed while the dispatch guard is deterministically \
4284 still held, even under real cross-thread concurrency (dispatch_key={dispatch_key}, \
4285 iteration={i}, contains_key_now={}, map_len_now={})",
4286 dispatching.contains_key(&dispatch_key),
4287 dispatching.len(),
4288 );
4289 tokio::task::yield_now().await;
4290 }
4291
4292 let _ = release_tx.send(());
4294 holder.await.unwrap();
4295
4296 tick_recover_stuck_tasks(
4298 &backend,
4299 &tmux,
4300 "owner/repo",
4301 &task_manager,
4302 &config,
4303 &store,
4304 &std::collections::HashMap::new(),
4305 &dispatching,
4306 )
4307 .await
4308 .unwrap();
4309 let task = store.get(id).await.unwrap();
4310 assert_eq!(
4311 task.status,
4312 crate::store::TaskStatus::New,
4313 "task should be reclaimed once the dispatch guard is released"
4314 );
4315 }
4316
4317 #[tokio::test]
4318 async fn recover_stuck_tasks_skips_external_in_review_waiting_for_human_review() {
4319 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4320 let mock = MockBackend::new();
4321 let status_updates = std::sync::Arc::clone(&mock.status_updates);
4322 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4323 let task_manager = Arc::new(TaskManager::with_store(
4324 std::sync::Arc::clone(&backend),
4325 std::sync::Arc::clone(&store),
4326 "owner/repo".to_string(),
4327 ));
4328 let tmux = Arc::new(TmuxManager::new());
4329 let config = EngineConfig {
4330 no_session_stuck_timeout: 600,
4331 stuck_timeout: 1800,
4332 ..EngineConfig::default()
4333 };
4334
4335 let id = store
4336 .upsert_external(&crate::store::UpsertExternal {
4337 repo: "owner/repo",
4338 ext_id: "101",
4339 title: "Waiting For Human Review",
4340 body: "",
4341 author: "",
4342 url: "",
4343 labels: &[],
4344 origin: "github",
4345 })
4346 .await
4347 .unwrap();
4348 store
4349 .update_status(id, crate::store::TaskStatus::InReview)
4350 .await
4351 .unwrap();
4352 set_task_updated_at_past(&store, id).await;
4353
4354 tick_recover_stuck_tasks(
4355 &backend,
4356 &tmux,
4357 "owner/repo",
4358 &task_manager,
4359 &config,
4360 &store,
4361 &std::collections::HashMap::new(),
4362 &Arc::new(DashMap::new()),
4363 )
4364 .await
4365 .unwrap();
4366
4367 let updates = status_updates.lock().unwrap();
4368 assert!(
4369 updates.is_empty(),
4370 "InReview task waiting for human review should not be reset"
4371 );
4372 }
4373
4374 #[tokio::test]
4375 async fn unblock_parents_no_blocked_tasks() {
4376 let mock = MockBackend::new(); let status_updates = std::sync::Arc::clone(&mock.status_updates);
4378 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4379 let task_manager = make_task_manager(std::sync::Arc::clone(&backend));
4380 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4381
4382 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
4383 .await
4384 .unwrap();
4385
4386 assert!(status_updates.lock().unwrap().is_empty());
4387 }
4388
4389 #[tokio::test]
4390 async fn unblock_parents_handles_multiple_blocked_tasks() {
4391 let mut mock = MockBackend::new();
4392
4393 let t40 = make_task("40", &["status:blocked"]);
4395 mock.blocked_tasks.push(t40);
4396 mock.sub_issues
4397 .insert("40".to_string(), vec![ExternalId("41".to_string())]);
4398 mock.tasks_by_id
4399 .insert("41".to_string(), make_task("41", &["status:done"]));
4400
4401 let t50 = make_task("50", &["status:blocked"]);
4403 mock.blocked_tasks.push(t50);
4404 mock.sub_issues
4405 .insert("50".to_string(), vec![ExternalId("51".to_string())]);
4406 mock.tasks_by_id
4407 .insert("51".to_string(), make_task("51", &["status:in_progress"]));
4408
4409 let status_updates = std::sync::Arc::clone(&mock.status_updates);
4410 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4411 let task_manager = make_task_manager(std::sync::Arc::clone(&backend));
4412 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4413
4414 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
4415 .await
4416 .unwrap();
4417
4418 let updates = status_updates.lock().unwrap();
4419 assert_eq!(updates.len(), 1, "only one parent should be unblocked");
4420 assert_eq!(updates[0].0, "40", "task 40 should be unblocked");
4421 assert_eq!(updates[0].1, Status::New);
4422 }
4423
4424 #[tokio::test]
4427 async fn tick_all_is_a_no_op_without_projects() {
4428 let transport = Arc::new(Transport::new());
4429 let capture = Arc::new(CaptureService::new(std::sync::Arc::clone(&transport)));
4430 let tmux = Arc::new(TmuxManager::new());
4431 let semaphore = Arc::new(Semaphore::new(1));
4432 let router = Arc::new(tokio::sync::RwLock::new(cooled_review_router_for_test()));
4433 let (weight_tx, mut weight_rx) = mpsc::channel(8);
4434 let dispatching = Arc::new(DashMap::new());
4435 let auto_merge_in_flight = Arc::new(DashSet::new());
4436
4437 tick_all(
4438 &[],
4439 &tmux,
4440 &capture,
4441 &semaphore,
4442 &EngineConfig::default(),
4443 &router,
4444 &weight_tx,
4445 &dispatching,
4446 Some(&transport),
4447 &std::collections::HashSet::new(),
4448 &auto_merge_in_flight,
4449 )
4450 .await;
4451
4452 assert!(dispatching.is_empty(), "no project, so no dispatch claimed");
4453 assert!(
4454 weight_rx.try_recv().is_err(),
4455 "no project, so no weight signal"
4456 );
4457 }
4458
4459 #[serial_test::serial(cooldown_state)]
4464 #[tokio::test]
4465 async fn tick_all_runs_the_global_needs_review_sweep_once_for_two_projects() {
4466 use crate::engine::router::{Router, RouterConfig};
4467 use crate::store::UpsertExternal;
4468
4469 crate::engine::cooldown::reset_global_state().await;
4470
4471 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4472 let id = store
4473 .upsert_external(&UpsertExternal {
4474 repo: "owner/one",
4475 ext_id: "7",
4476 title: "Stale needs_review task",
4477 body: "",
4478 author: "user",
4479 url: "https://github.com/owner/one/issues/7",
4480 labels: &[],
4481 origin: "github",
4482 })
4483 .await
4484 .unwrap();
4485 store
4486 .update_status(id, crate::store::TaskStatus::NeedsReview)
4487 .await
4488 .unwrap();
4489 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4490 .bind(id)
4491 .execute(store.pool())
4492 .await
4493 .unwrap();
4494
4495 let engines: Vec<ProjectEngine> = ["owner/one", "owner/two"]
4496 .iter()
4497 .map(|repo| {
4498 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4499 ProjectEngine {
4500 repo: repo.to_string(),
4501 project_dir: std::path::PathBuf::from("/nonexistent"),
4502 backend: std::sync::Arc::clone(&backend),
4503 task_manager: Arc::new(TaskManager::with_store(
4504 backend,
4505 std::sync::Arc::clone(&store),
4506 repo.to_string(),
4507 )),
4508 runner: Arc::new(crate::engine::runner::TaskRunner::new(repo.to_string())),
4509 store: std::sync::Arc::clone(&store),
4510 }
4511 })
4512 .collect();
4513
4514 let mut config = RouterConfig::default();
4516 config
4517 .model_map
4518 .entry("review".to_string())
4519 .or_default()
4520 .insert("claude".to_string(), vec!["sonnet".to_string()]);
4521 let router = Arc::new(tokio::sync::RwLock::new(Router::new_for_test(
4522 config,
4523 vec!["claude".to_string()],
4524 )));
4525
4526 let transport = Arc::new(Transport::new());
4527 let capture = Arc::new(CaptureService::new(std::sync::Arc::clone(&transport)));
4528 let (weight_tx, _weight_rx) = mpsc::channel(8);
4529
4530 tick_all(
4531 &engines,
4532 &Arc::new(TmuxManager::new()),
4533 &capture,
4534 &Arc::new(Semaphore::new(1)),
4535 &EngineConfig::default(),
4536 &router,
4537 &weight_tx,
4538 &Arc::new(DashMap::new()),
4539 Some(&transport),
4540 &engines.iter().map(|e| e.repo.clone()).collect(),
4541 &Arc::new(DashSet::new()),
4542 )
4543 .await;
4544
4545 assert_eq!(
4546 store.get(id).await.unwrap().needs_review_refires,
4547 1,
4548 "two projects must share one pass of the global NeedsReview sweep"
4549 );
4550 }
4551
4552 #[tokio::test]
4555 async fn tick_all_does_not_kill_sessions_it_did_not_create() {
4556 let tmux = Arc::new(TmuxManager::new());
4557 let Ok(session_name) = tmux
4558 .create_session("other/foreign", "3889", "/tmp", "sleep 30", &[])
4559 .await
4560 else {
4561 eprintln!("Skipping test: tmux not available or failed to create test session");
4562 return;
4563 };
4564 let _guard = SessionGuard(session_name.clone());
4565
4566 tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
4569
4570 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4571 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4572 let engines = vec![ProjectEngine {
4573 repo: "owner/one".to_string(),
4574 project_dir: std::path::PathBuf::from("/nonexistent"),
4575 backend: std::sync::Arc::clone(&backend),
4576 task_manager: Arc::new(TaskManager::with_store(
4577 backend,
4578 std::sync::Arc::clone(&store),
4579 "owner/one".to_string(),
4580 )),
4581 runner: Arc::new(crate::engine::runner::TaskRunner::new(
4582 "owner/one".to_string(),
4583 )),
4584 store,
4585 }];
4586
4587 let transport = Arc::new(Transport::new());
4588 let capture = Arc::new(CaptureService::new(std::sync::Arc::clone(&transport)));
4589 let (weight_tx, _weight_rx) = mpsc::channel(8);
4590 let router = Arc::new(tokio::sync::RwLock::new(cooled_review_router_for_test()));
4591
4592 tick_all(
4593 &engines,
4594 &tmux,
4595 &capture,
4596 &Arc::new(Semaphore::new(1)),
4597 &EngineConfig::default(),
4598 &router,
4599 &weight_tx,
4600 &Arc::new(DashMap::new()),
4601 Some(&transport),
4602 &engines.iter().map(|e| e.repo.clone()).collect(),
4603 &Arc::new(DashSet::new()),
4604 )
4605 .await;
4606
4607 assert!(
4608 tmux.session_exists(&session_name).await,
4609 "tick_all must not kill a tmux session it did not start"
4610 );
4611 }
4612
4613 #[tokio::test]
4614 async fn detect_silent_agents_skips_backend_for_internal_tasks() {
4615 let transport = Arc::new(Transport::new());
4616 let capture = Arc::new(CaptureService::new(transport));
4617 let mock = Arc::new(MockBackend::new());
4618 let backend: Arc<dyn ExternalBackend> = Arc::<MockBackend>::clone(&mock);
4619 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4620 let task_manager = Arc::new(TaskManager::with_store(
4621 std::sync::Arc::clone(&backend),
4622 std::sync::Arc::clone(&store),
4623 "owner/repo".to_string(),
4624 ));
4625 let tmux = Arc::new(TmuxManager::new());
4626 let config = EngineConfig {
4627 silence_grace_period: 0,
4628 ..EngineConfig::default()
4629 };
4630
4631 let internal_id = store
4632 .create_internal("owner/repo", "Silent internal", "", "cron", "1", None)
4633 .await
4634 .unwrap();
4635 store
4636 .update_status(internal_id, crate::store::TaskStatus::InProgress)
4637 .await
4638 .unwrap();
4639
4640 let task_id = format!("internal:{internal_id}");
4641 capture
4642 .register_session("owner/repo", &task_id, "orch-test-internal")
4643 .await;
4644 tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
4645
4646 tick_detect_silent_agents(
4647 &tmux,
4648 "owner/repo",
4649 &capture,
4650 &backend,
4651 &task_manager,
4652 &config,
4653 &store,
4654 )
4655 .await
4656 .unwrap();
4657
4658 assert!(
4659 mock.removed_labels.lock().unwrap().is_empty(),
4660 "internal tasks should skip backend label removal"
4661 );
4662 assert!(
4663 mock.posted_comments.lock().unwrap().is_empty(),
4664 "internal tasks should skip backend comments"
4665 );
4666
4667 let task = store.get(internal_id).await.unwrap();
4668 assert!(
4672 matches!(
4673 task.status,
4674 crate::store::TaskStatus::Routed | crate::store::TaskStatus::New
4675 ),
4676 "internal task should be routed to fallback or reset to new, got {:?}",
4677 task.status
4678 );
4679 }
4680
4681 #[tokio::test]
4682 async fn detect_silent_agents_finalizes_open_task_runs_rows() {
4683 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4684 let mock = MockBackend::new();
4685 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4686 let task_manager = Arc::new(TaskManager::with_store(
4687 std::sync::Arc::clone(&backend),
4688 std::sync::Arc::clone(&store),
4689 "owner/repo".to_string(),
4690 ));
4691 let transport = Arc::new(Transport::new());
4692 let capture = Arc::new(CaptureService::new(transport));
4693 let tmux = Arc::new(TmuxManager::new());
4694 let config = EngineConfig {
4695 silence_grace_period: 60,
4696 silence_cooldown: 300,
4697 ..EngineConfig::default()
4698 };
4699
4700 let internal_id = store
4701 .create_internal("owner/repo", "Silent with open run", "", "cron", "1", None)
4702 .await
4703 .unwrap();
4704 store
4705 .set_fields(
4706 internal_id,
4707 &[
4708 ("agent", serde_json::json!("claude")),
4709 ("model", serde_json::json!("sonnet")),
4710 ],
4711 )
4712 .await
4713 .unwrap();
4714 store
4715 .update_status(internal_id, crate::store::TaskStatus::InProgress)
4716 .await
4717 .unwrap();
4718
4719 let task_id = format!("internal:{internal_id}");
4720 capture
4721 .register_session("owner/repo", &task_id, "orch-test-silent-run")
4722 .await;
4723 capture
4724 .set_buffer_state_for_test(
4725 "owner/repo",
4726 &task_id,
4727 false,
4728 false,
4729 chrono::Utc::now() - chrono::Duration::seconds(120),
4730 )
4731 .await;
4732
4733 store
4735 .start_run(&crate::store::StartRun {
4736 task_id: internal_id,
4737 attempt: 1,
4738 run_type: "agent",
4739 agent: "claude",
4740 model: "sonnet",
4741 command: "claude --model sonnet",
4742 prompt: "fix it",
4743 })
4744 .await
4745 .unwrap();
4746
4747 tick_detect_silent_agents(
4748 &tmux,
4749 "owner/repo",
4750 &capture,
4751 &backend,
4752 &task_manager,
4753 &config,
4754 &store,
4755 )
4756 .await
4757 .unwrap();
4758
4759 let runs = store.get_runs(internal_id).await.unwrap();
4760 assert_eq!(runs.len(), 1);
4761 assert_eq!(runs[0].outcome, "aborted");
4762 assert!(runs[0].completed_at.is_some());
4763 assert_eq!(runs[0].error, "silence detection: session killed");
4764 }
4765
4766 #[tokio::test]
4777 async fn phase1_session_completion_touch_prevents_phase2_reclaim() {
4778 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4779 let mock = MockBackend::new();
4780 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4781 let task_manager = Arc::new(TaskManager::with_store(
4782 std::sync::Arc::clone(&backend),
4783 std::sync::Arc::clone(&store),
4784 "owner/repo".to_string(),
4785 ));
4786 let tmux = Arc::new(TmuxManager::new());
4787 let config = EngineConfig {
4788 no_session_stuck_timeout: 600,
4789 stuck_timeout: 1800,
4790 ..EngineConfig::default()
4791 };
4792
4793 let id = store
4797 .create_internal("owner/repo", "Completed task", "", "cron", "1", None)
4798 .await
4799 .unwrap();
4800 store
4801 .update_status(id, crate::store::TaskStatus::InProgress)
4802 .await
4803 .unwrap();
4804 set_task_updated_at_past(&store, id).await;
4805
4806 let task_id = format!("internal:{id}");
4809 store_touch_updated_at(&Some(Arc::clone(&store)), "owner/repo", &task_id).await;
4810
4811 tick_recover_stuck_tasks(
4813 &backend,
4814 &tmux,
4815 "owner/repo",
4816 &task_manager,
4817 &config,
4818 &store,
4819 &std::collections::HashMap::new(),
4820 &Arc::new(DashMap::new()),
4821 )
4822 .await
4823 .unwrap();
4824
4825 let task = store.get(id).await.unwrap();
4826 assert_eq!(
4827 task.status,
4828 crate::store::TaskStatus::InProgress,
4829 "Phase 2 must not reclaim a task whose updated_at was just touched by Phase 1"
4830 );
4831 }
4832
4833 #[tokio::test]
4834 async fn stuck_task_timing_treats_dead_session_as_missing() {
4835 let tmux = Arc::new(TmuxManager::new());
4836 let repo = "owner/repo";
4837 let task_id = "internal:33498";
4838 let session_name = tmux.session_name(repo, task_id);
4839
4840 let create_result = tmux
4841 .command()
4842 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
4843 .output()
4844 .await;
4845
4846 match create_result {
4847 Ok(o) if o.status.success() => {}
4848 _ => {
4849 eprintln!("Skipping test: tmux not available or failed to create test session");
4850 return;
4851 }
4852 }
4853
4854 let _guard = SessionGuard(session_name.clone());
4855
4856 let set_option_result = tmux
4857 .command()
4858 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
4859 .output()
4860 .await;
4861 if !matches!(set_option_result, Ok(ref o) if o.status.success()) {
4862 eprintln!("Skipping test: unable to set tmux remain-on-exit option");
4863 return;
4864 }
4865
4866 let send_exit_result = tmux
4867 .command()
4868 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
4869 .output()
4870 .await;
4871 if !matches!(send_exit_result, Ok(ref o) if o.status.success()) {
4872 eprintln!("Skipping test: unable to exit tmux pane");
4873 return;
4874 }
4875
4876 for _ in 0..20 {
4878 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4879 if !tmux.session_is_running(&session_name).await {
4880 break;
4881 }
4882 }
4883
4884 let mut session_map = tmux.batch_session_active().await;
4889 for _ in 0..20 {
4890 if !session_map.get(&session_name).copied().unwrap_or(false) {
4891 break;
4892 }
4893 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4894 session_map = tmux.batch_session_active().await;
4895 }
4896 if session_map.get(&session_name).copied().unwrap_or(false) {
4897 eprintln!(
4898 "Skipping test: session still alive in batch_session_active after retries (CI tmux lag)"
4899 );
4900 return;
4901 }
4902
4903 let config = EngineConfig {
4904 no_session_stuck_timeout: 600,
4905 stuck_timeout: 1800,
4906 ..EngineConfig::default()
4907 };
4908 let updated_at = (chrono::Utc::now()
4909 - chrono::Duration::seconds(
4910 i64::try_from(config.no_session_stuck_timeout).expect("timeout fits in i64") + 5,
4911 ))
4912 .to_rfc3339();
4913
4914 let timing = stuck_task_timing_from_map(
4915 &tmux,
4916 repo,
4917 task_id,
4918 &updated_at,
4919 &config,
4920 "parse failure",
4921 &session_map,
4922 )
4923 .expect("dead session should be treated as missing");
4924
4925 assert!(!timing.has_session);
4926 assert_eq!(timing.threshold, config.no_session_stuck_timeout);
4927 }
4928
4929 #[tokio::test]
4930 #[serial_test::serial(suspend_state)]
4931 async fn stuck_task_timing_discounts_host_suspend_gap() {
4932 super::super::suspend::clear_for_test();
4933
4934 let tmux = Arc::new(TmuxManager::new());
4935 let repo = "owner/repo";
4936 let task_id = "internal:99001";
4937 let session_map = std::collections::HashMap::new();
4938
4939 let config = EngineConfig {
4940 no_session_stuck_timeout: 1800,
4941 stuck_timeout: 1800,
4942 ..EngineConfig::default()
4943 };
4944
4945 let updated_at = (chrono::Utc::now() - chrono::Duration::minutes(33)).to_rfc3339();
4949 let suspend_gap_detected_at = chrono::Utc::now() - chrono::Duration::minutes(1);
4950 super::super::suspend::inject_gap_for_test(
4951 suspend_gap_detected_at,
4952 chrono::Duration::minutes(30),
4953 );
4954
4955 let timing = stuck_task_timing_from_map(
4956 &tmux,
4957 repo,
4958 task_id,
4959 &updated_at,
4960 &config,
4961 "parse failure",
4962 &session_map,
4963 );
4964
4965 assert!(
4966 timing.is_none(),
4967 "task age should be discounted below the stuck threshold once suspend time is subtracted, got {timing:?}"
4968 );
4969
4970 super::super::suspend::clear_for_test();
4971 }
4972
4973 #[tokio::test]
4974 async fn recover_stuck_tasks_reclaims_routed_task_blocked_by_stale_session() {
4975 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4976 let mock = MockBackend::new();
4977 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4978 let task_manager = Arc::new(TaskManager::with_store(
4979 std::sync::Arc::clone(&backend),
4980 std::sync::Arc::clone(&store),
4981 "owner/repo".to_string(),
4982 ));
4983 let tmux = Arc::new(TmuxManager::new());
4984 let config = EngineConfig {
4985 no_session_stuck_timeout: 600,
4986 stuck_timeout: 1,
4987 ..EngineConfig::default()
4988 };
4989
4990 let id = store
4991 .create_internal("owner/repo", "Blocked routed task", "", "cron", "1", None)
4992 .await
4993 .unwrap();
4994 store
4995 .update_status(id, crate::store::TaskStatus::Routed)
4996 .await
4997 .unwrap();
4998 set_task_updated_at_past(&store, id).await;
4999
5000 let task_id = format!("internal:{id}");
5001 let session_name = tmux.session_name("owner/repo", &task_id);
5002 let create_result = tmux
5003 .command()
5004 .args([
5005 "new-session",
5006 "-d",
5007 "-s",
5008 &session_name,
5009 "-c",
5010 "/tmp",
5011 "sleep 60",
5012 ])
5013 .output()
5014 .await;
5015
5016 match create_result {
5017 Ok(o) if o.status.success() => {}
5018 _ => {
5019 eprintln!("Skipping test: tmux not available or failed to create test session");
5020 return;
5021 }
5022 }
5023
5024 let session_map = tmux.batch_session_active().await;
5025 assert_eq!(session_map.get(&session_name).copied(), Some(true));
5026
5027 tick_recover_stuck_tasks(
5028 &backend,
5029 &tmux,
5030 "owner/repo",
5031 &task_manager,
5032 &config,
5033 &store,
5034 &session_map,
5035 &Arc::new(DashMap::new()),
5036 )
5037 .await
5038 .unwrap();
5039
5040 let task = store.get(id).await.unwrap();
5041 assert_eq!(
5042 task.status,
5043 crate::store::TaskStatus::New,
5044 "routed task blocked by a stale session should be reset for re-routing"
5045 );
5046 assert!(
5047 !tmux.session_exists(&session_name).await,
5048 "recovery should kill the stale tmux session so dispatch can proceed"
5049 );
5050 }
5051
5052 #[tokio::test]
5066 async fn dispatch_does_not_deadlock_under_write_lock() {
5067 use crate::engine::router::Router;
5068 use tokio::sync::RwLock;
5069
5070 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5071 let mock = MockBackend::new();
5072 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5073 let task_manager = Arc::new(TaskManager::with_store(
5074 std::sync::Arc::clone(&backend),
5075 std::sync::Arc::clone(&store),
5076 "owner/repo".to_string(),
5077 ));
5078 let tmux = Arc::new(TmuxManager::new());
5079 let semaphore = Arc::new(Semaphore::new(4));
5080 let (weight_tx, _weight_rx) = mpsc::channel(16);
5081 let dispatching = Arc::new(DashMap::new());
5082 let transport = Arc::new(Transport::new());
5083 let capture = Arc::new(CaptureService::new(transport));
5084
5085 let router_config = crate::engine::router::RouterConfig::default();
5086 let router = Router::new_for_test(router_config, vec!["claude".to_string()]);
5087 let router_arc = Arc::new(RwLock::new(router));
5088
5089 let write_guard = router_arc.write().await;
5093 let dispatch_mode = dispatch_mode_from_router(&write_guard);
5094
5095 let result = tokio::time::timeout(
5096 std::time::Duration::from_secs(2),
5097 tick_dispatch_tasks(
5098 &backend,
5099 &tmux,
5100 "owner/repo",
5101 &Arc::new(crate::engine::runner::TaskRunner::new(
5102 "owner/repo".to_string(),
5103 )),
5104 &capture,
5105 &semaphore,
5106 &task_manager,
5107 &weight_tx,
5108 dispatch_mode,
5109 &dispatching,
5110 &store,
5111 &std::collections::HashMap::new(),
5112 ),
5113 )
5114 .await;
5115
5116 assert!(
5117 result.is_ok(),
5118 "tick_dispatch_tasks deadlocked! It tried to acquire a read lock \
5119 on router_arc while a write lock was already held (issue #1361)"
5120 );
5121 }
5122
5123 #[tokio::test]
5131 async fn dispatch_skips_task_whose_routed_model_entered_cooldown() {
5132 use crate::store::{StoreRoute, TaskStatus as DbStatus, UpsertExternal};
5133
5134 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5135 let mock = MockBackend::new();
5136 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5137 let task_manager = Arc::new(TaskManager::with_store(
5138 std::sync::Arc::clone(&backend),
5139 std::sync::Arc::clone(&store),
5140 "owner/repo".to_string(),
5141 ));
5142 let tmux = Arc::new(TmuxManager::new());
5143 let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
5144 let semaphore = Arc::new(Semaphore::new(4));
5145 let (weight_tx, _weight_rx) = mpsc::channel(16);
5146 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5147 let transport = Arc::new(Transport::new());
5148 let capture = Arc::new(CaptureService::new(transport));
5149
5150 let id = store
5151 .upsert_external(&UpsertExternal {
5152 repo: "owner/repo",
5153 ext_id: "999",
5154 title: "Stale routed task",
5155 body: "",
5156 author: "user",
5157 url: "https://github.com/owner/repo/issues/999",
5158 labels: &[],
5159 origin: "github",
5160 })
5161 .await
5162 .unwrap();
5163 store
5164 .store_route(&StoreRoute {
5165 id,
5166 agent: "minimax",
5167 model: Some("opus"),
5168 complexity: "medium",
5169 estimate: 0,
5170 reason: "test",
5171 profile: "{}",
5172 skills: "[]",
5173 })
5174 .await
5175 .unwrap();
5176 store.update_status(id, DbStatus::Routed).await.unwrap();
5177
5178 crate::engine::cooldown::set_model_cooldown(
5181 "minimax",
5182 "opus",
5183 300,
5184 "rate_limit_usage_window",
5185 )
5186 .await;
5187
5188 let dispatch_mode = DispatchMode {
5189 is_degraded: false,
5190 healthy_agents: 1,
5191 threshold: 1,
5192 };
5193
5194 tick_dispatch_tasks(
5195 &backend,
5196 &tmux,
5197 "owner/repo",
5198 &runner,
5199 &capture,
5200 &semaphore,
5201 &task_manager,
5202 &weight_tx,
5203 dispatch_mode,
5204 &dispatching,
5205 &store,
5206 &std::collections::HashMap::new(),
5207 )
5208 .await
5209 .unwrap();
5210
5211 let task = store.get(id).await.unwrap();
5212 assert_eq!(
5213 task.status,
5214 DbStatus::Routed,
5215 "task whose routed model is now cooled down must stay Routed, not be dispatched"
5216 );
5217 assert!(
5218 dispatching.is_empty(),
5219 "dispatch guard must be released when skipping a stale-cooldown task"
5220 );
5221 }
5222
5223 #[tokio::test]
5224 async fn dispatch_skips_a_task_with_an_open_dependency() {
5225 use crate::store::{
5226 DepSource, NewDependency, StoreRoute, TaskStatus as DbStatus, UpsertExternal,
5227 };
5228
5229 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5230 let mock = MockBackend::new();
5231 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5232 let task_manager = Arc::new(TaskManager::with_store(
5233 std::sync::Arc::clone(&backend),
5234 std::sync::Arc::clone(&store),
5235 "owner/repo".to_string(),
5236 ));
5237 let tmux = Arc::new(TmuxManager::new());
5238 let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
5239 let semaphore = Arc::new(Semaphore::new(4));
5240 let (weight_tx, _weight_rx) = mpsc::channel(16);
5241 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5242 let transport = Arc::new(Transport::new());
5243 let capture = Arc::new(CaptureService::new(transport));
5244
5245 let id = store
5246 .upsert_external(&UpsertExternal {
5247 repo: "owner/repo",
5248 ext_id: "42",
5249 title: "Waiting routed task",
5250 body: "",
5251 author: "user",
5252 url: "https://github.com/owner/repo/issues/42",
5253 labels: &[],
5254 origin: "github",
5255 })
5256 .await
5257 .unwrap();
5258 store
5259 .replace_dependencies(
5260 id,
5261 &[NewDependency {
5262 repo: "owner/repo".into(),
5263 number: 7,
5264 source: DepSource::Github,
5265 }],
5266 )
5267 .await
5268 .unwrap();
5269 store
5270 .store_route(&StoreRoute {
5271 id,
5272 agent: "claude",
5273 model: Some("haiku"),
5274 complexity: "simple",
5275 estimate: 0,
5276 reason: "test",
5277 profile: "{}",
5278 skills: "[]",
5279 })
5280 .await
5281 .unwrap();
5282 store.update_status(id, DbStatus::Routed).await.unwrap();
5283
5284 let dispatch_mode = DispatchMode {
5285 is_degraded: false,
5286 healthy_agents: 1,
5287 threshold: 1,
5288 };
5289 tick_dispatch_tasks(
5290 &backend,
5291 &tmux,
5292 "owner/repo",
5293 &runner,
5294 &capture,
5295 &semaphore,
5296 &task_manager,
5297 &weight_tx,
5298 dispatch_mode,
5299 &dispatching,
5300 &store,
5301 &std::collections::HashMap::new(),
5302 )
5303 .await
5304 .unwrap();
5305
5306 let task = store.get(id).await.unwrap();
5307 assert_eq!(task.status, DbStatus::Waiting);
5308 assert!(dispatching.is_empty());
5309 }
5310
5311 #[tokio::test]
5324 async fn completed_session_does_not_cause_stuck_reclaim_race() {
5325 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5326 let mock = MockBackend::new();
5327 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5328 let task_manager = Arc::new(TaskManager::with_store(
5329 std::sync::Arc::clone(&backend),
5330 std::sync::Arc::clone(&store),
5331 "owner/repo".to_string(),
5332 ));
5333 let tmux = Arc::new(TmuxManager::new());
5334 let transport = Arc::new(Transport::new());
5335 let capture = Arc::new(CaptureService::new(transport));
5336
5337 let id = store
5339 .upsert_external(&crate::store::UpsertExternal {
5340 repo: "owner/repo",
5341 ext_id: "200",
5342 title: "Race Task",
5343 body: "",
5344 author: "",
5345 url: "",
5346 labels: &[],
5347 origin: "github",
5348 })
5349 .await
5350 .unwrap();
5351 store
5352 .update_status(id, crate::store::TaskStatus::InProgress)
5353 .await
5354 .unwrap();
5355
5356 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
5358 .bind(id)
5359 .execute(store.pool())
5360 .await
5361 .unwrap();
5362
5363 let task_id = "200";
5365 let session_name = tmux.session_name("owner/repo", task_id);
5366 let create_result = tmux
5367 .command()
5368 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
5369 .output()
5370 .await;
5371 match create_result {
5372 Ok(o) if o.status.success() => {}
5373 _ => {
5374 eprintln!("Skipping test: tmux not available or failed to create test session");
5375 return;
5376 }
5377 }
5378 let _guard = SessionGuard(session_name.clone());
5379
5380 let _ = tmux
5382 .command()
5383 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
5384 .output()
5385 .await;
5386 let _ = tmux
5387 .command()
5388 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
5389 .output()
5390 .await;
5391
5392 for _ in 0..20 {
5394 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5395 if !tmux.session_is_running(&session_name).await {
5396 break;
5397 }
5398 }
5399
5400 let task_key = task_id.to_string();
5402 capture
5403 .register_session("owner/repo", &task_key, &session_name)
5404 .await;
5405
5406 let config = EngineConfig {
5408 no_session_stuck_timeout: 600,
5409 stuck_timeout: 1800,
5410 ..EngineConfig::default()
5411 };
5412
5413 tick_check_session_completions(&tmux, "owner/repo", &capture, &store)
5414 .await
5415 .unwrap();
5416
5417 {
5423 let task_state = store.get(id).await.unwrap();
5424 if task_state.updated_at.starts_with("2020") {
5425 eprintln!(
5426 "Skipping test: tick_check_session_completions did not update stored \
5427 updated_at — session was not in snapshot (CI tmux environment issue)"
5428 );
5429 return;
5430 }
5431 }
5432
5433 tick_recover_stuck_tasks(
5435 &backend,
5436 &tmux,
5437 "owner/repo",
5438 &task_manager,
5439 &config,
5440 &store,
5441 &std::collections::HashMap::new(),
5442 &Arc::new(DashMap::new()),
5443 )
5444 .await
5445 .unwrap();
5446
5447 let task = store.get(id).await.unwrap();
5449 assert_eq!(
5450 task.status,
5451 crate::store::TaskStatus::InProgress,
5452 "task should not be reclaimed to New"
5453 );
5454 }
5455
5456 #[tokio::test]
5466 async fn session_completion_matches_repo_configured_with_git_suffix() {
5467 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5468 let tmux = Arc::new(TmuxManager::new());
5469 let transport = Arc::new(Transport::new());
5470 let capture = Arc::new(CaptureService::new(transport));
5471
5472 let repo = "owner/repo.git";
5473 let id = store
5474 .upsert_external(&crate::store::UpsertExternal {
5475 repo,
5476 ext_id: "201",
5477 title: "Git-suffix repo task",
5478 body: "",
5479 author: "",
5480 url: "",
5481 labels: &[],
5482 origin: "github",
5483 })
5484 .await
5485 .unwrap();
5486 store
5487 .update_status(id, crate::store::TaskStatus::InProgress)
5488 .await
5489 .unwrap();
5490 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
5491 .bind(id)
5492 .execute(store.pool())
5493 .await
5494 .unwrap();
5495
5496 let task_id = "201";
5499 let session_name = tmux.session_name(repo, task_id);
5500 let create_result = tmux
5501 .command()
5502 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
5503 .output()
5504 .await;
5505 match create_result {
5506 Ok(o) if o.status.success() => {}
5507 _ => {
5508 eprintln!("Skipping test: tmux not available or failed to create test session");
5509 return;
5510 }
5511 }
5512 let _guard = SessionGuard(session_name.clone());
5513
5514 let _ = tmux
5515 .command()
5516 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
5517 .output()
5518 .await;
5519 let _ = tmux
5520 .command()
5521 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
5522 .output()
5523 .await;
5524
5525 for _ in 0..20 {
5526 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5527 if !tmux.session_is_running(&session_name).await {
5528 break;
5529 }
5530 }
5531
5532 capture.register_session(repo, task_id, &session_name).await;
5533
5534 tick_check_session_completions(&tmux, repo, &capture, &store)
5535 .await
5536 .unwrap();
5537
5538 let task_state = store.get(id).await.unwrap();
5539 if task_state.updated_at.starts_with("2020") {
5540 eprintln!(
5541 "Skipping test: tick_check_session_completions did not update stored \
5542 updated_at — session was not in snapshot (CI tmux environment issue)"
5543 );
5544 return;
5545 }
5546 assert!(
5547 !task_state.updated_at.starts_with("2020"),
5548 "tick_check_session_completions must match its own repo's live sessions \
5549 even when the repo is configured with a \".git\" suffix"
5550 );
5551 }
5552
5553 #[tokio::test]
5559 async fn job_scheduler_returns_ok_on_duplicate_job_config() {
5560 let tmp = tempfile::tempdir().unwrap();
5561 let cfg = tmp.path().join(".orch.yml");
5562 std::fs::write(
5563 &cfg,
5564 "jobs:\n - id: trading-scan\n schedule: \"0 9 * * *\"\n task:\n title: Scan\n body: do it\n",
5565 )
5566 .unwrap();
5567 let jobs_dir = tmp.path().join("prompts").join("jobs");
5568 std::fs::create_dir_all(&jobs_dir).unwrap();
5569 std::fs::write(
5570 jobs_dir.join("trading-scan.md"),
5571 "---\nid: trading-scan\nschedule: '0 9 * * *'\ntitle: Trading Scan\n---\n\nDo trading scan.\n",
5572 )
5573 .unwrap();
5574
5575 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5576 let result = tick_job_scheduler(&cfg, &backend, None, "test/repo", None).await;
5577 assert!(
5578 result.is_ok(),
5579 "duplicate job id must not propagate as Err: {result:?}"
5580 );
5581 }
5582
5583 #[tokio::test]
5587 async fn job_scheduler_processes_valid_project_when_other_has_duplicate() {
5588 let bad_tmp = tempfile::tempdir().unwrap();
5590 let bad_cfg = bad_tmp.path().join(".orch.yml");
5591 std::fs::write(
5592 &bad_cfg,
5593 "jobs:\n - id: dup\n schedule: \"0 9 * * *\"\n task:\n title: Dup\n body: x\n",
5594 )
5595 .unwrap();
5596 let bad_jobs_dir = bad_tmp.path().join("prompts").join("jobs");
5597 std::fs::create_dir_all(&bad_jobs_dir).unwrap();
5598 std::fs::write(
5599 bad_jobs_dir.join("dup.md"),
5600 "---\nid: dup\nschedule: '0 9 * * *'\ntitle: Dup\n---\n\nbody\n",
5601 )
5602 .unwrap();
5603
5604 let good_tmp = tempfile::tempdir().unwrap();
5606 let good_cfg = good_tmp.path().join(".orch.yml");
5607 std::fs::write(
5608 &good_cfg,
5609 "jobs:\n - id: valid-job\n schedule: \"0 9 * * *\"\n task:\n title: Valid\n body: ok\n",
5610 )
5611 .unwrap();
5612
5613 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5614
5615 let bad_result = tick_job_scheduler(&bad_cfg, &backend, None, "test/bad-repo", None).await;
5616 assert!(
5617 bad_result.is_ok(),
5618 "bad project must not abort scheduler: {bad_result:?}"
5619 );
5620
5621 let good_result =
5622 tick_job_scheduler(&good_cfg, &backend, None, "test/good-repo", None).await;
5623 assert!(
5624 good_result.is_ok(),
5625 "valid project must still run after bad project: {good_result:?}"
5626 );
5627 }
5628
5629 #[serial_test::serial(cooldown_state)]
5637 #[tokio::test]
5638 async fn global_sweep_routes_inactive_repo_tasks() {
5639 use crate::engine::router::{Router, RouterConfig};
5640 use crate::store::{TaskStatus, UpsertExternal};
5641
5642 crate::engine::cooldown::reset_global_state().await;
5643
5644 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5645
5646 let active_id = store
5648 .upsert_external(&UpsertExternal {
5649 repo: "active/repo",
5650 ext_id: "1",
5651 title: "Active task",
5652 body: "",
5653 author: "user",
5654 url: "https://github.com/active/repo/issues/1",
5655 labels: &[],
5656 origin: "github",
5657 })
5658 .await
5659 .unwrap();
5660
5661 let inactive_id = store
5663 .upsert_external(&UpsertExternal {
5664 repo: "inactive/repo",
5665 ext_id: "42",
5666 title: "Orphaned task",
5667 body: "",
5668 author: "user",
5669 url: "https://github.com/inactive/repo/issues/42",
5670 labels: &[],
5671 origin: "github",
5672 })
5673 .await
5674 .unwrap();
5675
5676 assert_eq!(store.get(active_id).await.unwrap().status, TaskStatus::New);
5677 assert_eq!(
5678 store.get(inactive_id).await.unwrap().status,
5679 TaskStatus::New
5680 );
5681
5682 let mut model_map: std::collections::HashMap<
5683 String,
5684 std::collections::HashMap<String, Vec<String>>,
5685 > = std::collections::HashMap::new();
5686 let mut tier = std::collections::HashMap::new();
5687 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5688 model_map.insert("medium".to_string(), tier);
5689 let config = RouterConfig {
5690 mode: "round_robin".to_string(),
5691 agents: vec!["claude".to_string()],
5692 model_map,
5693 ..Default::default()
5694 };
5695 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5696
5697 let active_repos: std::collections::HashSet<String> =
5698 ["active/repo".to_string()].into_iter().collect();
5699 let mut routing_quota = RoutingTickQuota::new(1);
5700 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5701 .await
5702 .unwrap();
5703
5704 assert_eq!(
5706 store.get(active_id).await.unwrap().status,
5707 TaskStatus::New,
5708 "global sweep must not touch tasks for the active repo"
5709 );
5710
5711 let after = store.get(inactive_id).await.unwrap();
5713 assert_eq!(
5714 after.status,
5715 TaskStatus::Routed,
5716 "global sweep must route tasks from inactive repos"
5717 );
5718 assert!(
5719 matches!(&after.agent, Some(a) if !a.is_empty()),
5720 "agent must be set after routing"
5721 );
5722 }
5723
5724 #[serial_test::serial(cooldown_state)]
5732 #[tokio::test]
5733 async fn global_sweep_does_not_touch_other_active_repo_tasks() {
5734 use crate::engine::router::{Router, RouterConfig};
5735 use crate::store::{TaskStatus, UpsertExternal};
5736
5737 crate::engine::cooldown::reset_global_state().await;
5738
5739 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5740
5741 let other_active_id = store
5744 .upsert_external(&UpsertExternal {
5745 repo: "other-active/repo",
5746 ext_id: "7",
5747 title: "Other active project's task",
5748 body: "",
5749 author: "user",
5750 url: "https://github.com/other-active/repo/issues/7",
5751 labels: &[],
5752 origin: "github",
5753 })
5754 .await
5755 .unwrap();
5756
5757 let inactive_id = store
5759 .upsert_external(&UpsertExternal {
5760 repo: "inactive/repo",
5761 ext_id: "42",
5762 title: "Orphaned task",
5763 body: "",
5764 author: "user",
5765 url: "https://github.com/inactive/repo/issues/42",
5766 labels: &[],
5767 origin: "github",
5768 })
5769 .await
5770 .unwrap();
5771
5772 let mut model_map: std::collections::HashMap<
5773 String,
5774 std::collections::HashMap<String, Vec<String>>,
5775 > = std::collections::HashMap::new();
5776 let mut tier = std::collections::HashMap::new();
5777 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5778 model_map.insert("medium".to_string(), tier);
5779 let config = RouterConfig {
5780 mode: "round_robin".to_string(),
5781 agents: vec!["claude".to_string()],
5782 model_map,
5783 ..Default::default()
5784 };
5785 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5786
5787 let active_repos: std::collections::HashSet<String> =
5791 ["active/repo".to_string(), "other-active/repo".to_string()]
5792 .into_iter()
5793 .collect();
5794 let mut routing_quota = RoutingTickQuota::new(1);
5795 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5796 .await
5797 .unwrap();
5798
5799 assert_eq!(
5800 store.get(other_active_id).await.unwrap().status,
5801 TaskStatus::New,
5802 "global sweep run from a different active repo's tick must not route \
5803 another active repo's new tasks — that repo's own tick will route them"
5804 );
5805 assert_eq!(
5806 store.get(inactive_id).await.unwrap().status,
5807 TaskStatus::Routed,
5808 "global sweep must still route tasks from genuinely orphaned repos"
5809 );
5810 }
5811
5812 #[serial_test::serial(cooldown_state)]
5814 #[tokio::test]
5815 async fn global_sweep_skips_no_agent_tasks() {
5816 use crate::engine::router::{Router, RouterConfig};
5817 use crate::store::{TaskStatus, UpsertExternal};
5818
5819 crate::engine::cooldown::reset_global_state().await;
5820
5821 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5822
5823 let id = store
5824 .upsert_external(&UpsertExternal {
5825 repo: "inactive/repo",
5826 ext_id: "99",
5827 title: "No-agent task",
5828 body: "",
5829 author: "user",
5830 url: "https://github.com/inactive/repo/issues/99",
5831 labels: &["no-agent".to_string()],
5832 origin: "github",
5833 })
5834 .await
5835 .unwrap();
5836
5837 let mut model_map: std::collections::HashMap<
5838 String,
5839 std::collections::HashMap<String, Vec<String>>,
5840 > = std::collections::HashMap::new();
5841 let mut tier = std::collections::HashMap::new();
5842 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5843 model_map.insert("medium".to_string(), tier);
5844 let config = RouterConfig {
5845 mode: "round_robin".to_string(),
5846 agents: vec!["claude".to_string()],
5847 model_map,
5848 ..Default::default()
5849 };
5850 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5851
5852 let active_repos: std::collections::HashSet<String> =
5853 ["active/repo".to_string()].into_iter().collect();
5854 let mut routing_quota = RoutingTickQuota::new(1);
5855 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5856 .await
5857 .unwrap();
5858
5859 assert_eq!(
5860 store.get(id).await.unwrap().status,
5861 TaskStatus::New,
5862 "no-agent tasks must not be routed"
5863 );
5864 }
5865
5866 #[serial_test::serial(cooldown_state)]
5871 #[tokio::test]
5872 async fn global_sweep_honors_shared_routing_quota() {
5873 use crate::engine::router::{Router, RouterConfig};
5874 use crate::store::{TaskStatus, UpsertExternal};
5875
5876 crate::engine::cooldown::reset_global_state().await;
5877
5878 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5879 for ext_id in ["1", "2"] {
5880 store
5881 .upsert_external(&UpsertExternal {
5882 repo: "inactive/repo",
5883 ext_id,
5884 title: "Orphaned task",
5885 body: "",
5886 author: "user",
5887 url: &format!("https://github.com/inactive/repo/issues/{ext_id}"),
5888 labels: &[],
5889 origin: "github",
5890 })
5891 .await
5892 .unwrap();
5893 }
5894
5895 let mut model_map: std::collections::HashMap<
5896 String,
5897 std::collections::HashMap<String, Vec<String>>,
5898 > = std::collections::HashMap::new();
5899 let mut tier = std::collections::HashMap::new();
5900 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5901 model_map.insert("medium".to_string(), tier);
5902 let config = RouterConfig {
5903 mode: "round_robin".to_string(),
5904 agents: vec!["claude".to_string()],
5905 model_map,
5906 ..Default::default()
5907 };
5908 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5909
5910 let active_repos: std::collections::HashSet<String> =
5911 ["active/repo".to_string()].into_iter().collect();
5912 let mut routing_quota = RoutingTickQuota::new(1);
5913 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5914 .await
5915 .unwrap();
5916
5917 let all_tasks = store.list_all_global().await.unwrap();
5918 let routed = all_tasks
5919 .iter()
5920 .filter(|task| task.status == TaskStatus::Routed)
5921 .count();
5922 let still_new = all_tasks
5923 .iter()
5924 .filter(|task| task.status == TaskStatus::New)
5925 .count();
5926
5927 assert_eq!(routed, 1, "only one task may consume the shared quota");
5928 assert_eq!(still_new, 1, "remaining tasks wait for the next tick");
5929
5930 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5931 .await
5932 .unwrap();
5933 let after_exhausted = store.list_all_global().await.unwrap();
5934 assert_eq!(
5935 after_exhausted
5936 .iter()
5937 .filter(|task| task.status == TaskStatus::Routed)
5938 .count(),
5939 1,
5940 "an exhausted shared quota must not reset within the same tick"
5941 );
5942 }
5943
5944 #[tokio::test]
5961 async fn group_orphaned_routed_tasks_selects_inactive_repo_tasks() {
5962 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
5963
5964 async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
5965 let id = store
5966 .upsert_external(&UpsertExternal {
5967 repo,
5968 ext_id,
5969 title: "Routed task",
5970 body: "",
5971 author: "user",
5972 url: &format!("https://github.com/{repo}/issues/{ext_id}"),
5973 labels: &[],
5974 origin: "github",
5975 })
5976 .await
5977 .unwrap();
5978 store
5979 .store_route(&StoreRoute {
5980 id,
5981 agent: "claude",
5982 model: Some("sonnet"),
5983 complexity: "medium",
5984 estimate: 0,
5985 reason: "test",
5986 profile: "{}",
5987 skills: "[]",
5988 })
5989 .await
5990 .unwrap();
5991 store.update_status(id, TaskStatus::Routed).await.unwrap();
5992 id
5993 }
5994
5995 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5996
5997 let active_id = make_routed(&store, "active/repo", "1").await;
5998 let inactive_id_a = make_routed(&store, "inactive/repo", "2").await;
5999 let other_inactive_id = make_routed(&store, "other-inactive/repo", "3").await;
6000 let inactive_id_b = make_routed(&store, "inactive/repo", "4").await;
6001
6002 let all_routed = store
6003 .list_all_by_status_global(TaskStatus::Routed)
6004 .await
6005 .unwrap();
6006 let active_repos: std::collections::HashSet<String> =
6007 ["active/repo".to_string()].into_iter().collect();
6008 let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
6009
6010 assert!(
6011 !by_repo.contains_key("active/repo"),
6012 "global sweep must not select tasks for the active repo"
6013 );
6014 let inactive_ids: std::collections::HashSet<i64> = by_repo
6015 .get("inactive/repo")
6016 .expect("inactive/repo must be selected")
6017 .iter()
6018 .map(|t| t.id)
6019 .collect();
6020 assert_eq!(
6021 inactive_ids,
6022 [inactive_id_a, inactive_id_b].into_iter().collect(),
6023 "global sweep must group all orphaned tasks for a repo together"
6024 );
6025 assert_eq!(
6026 by_repo
6027 .get("other-inactive/repo")
6028 .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
6029 Some(vec![other_inactive_id])
6030 );
6031
6032 assert_eq!(
6035 store.get(active_id).await.unwrap().status,
6036 TaskStatus::Routed
6037 );
6038 }
6039
6040 #[tokio::test]
6045 async fn group_orphaned_routed_tasks_excludes_all_active_repos_not_just_current() {
6046 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
6047
6048 async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
6049 let id = store
6050 .upsert_external(&UpsertExternal {
6051 repo,
6052 ext_id,
6053 title: "Routed task",
6054 body: "",
6055 author: "user",
6056 url: &format!("https://github.com/{repo}/issues/{ext_id}"),
6057 labels: &[],
6058 origin: "github",
6059 })
6060 .await
6061 .unwrap();
6062 store
6063 .store_route(&StoreRoute {
6064 id,
6065 agent: "claude",
6066 model: Some("sonnet"),
6067 complexity: "medium",
6068 estimate: 0,
6069 reason: "test",
6070 profile: "{}",
6071 skills: "[]",
6072 })
6073 .await
6074 .unwrap();
6075 store.update_status(id, TaskStatus::Routed).await.unwrap();
6076 id
6077 }
6078
6079 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6080
6081 let repo_a_id = make_routed(&store, "active/repo-a", "1").await;
6082 let repo_b_id = make_routed(&store, "active/repo-b", "2").await;
6083 let orphaned_id = make_routed(&store, "inactive/repo", "3").await;
6084
6085 let all_routed = store
6086 .list_all_by_status_global(TaskStatus::Routed)
6087 .await
6088 .unwrap();
6089 let active_repos: std::collections::HashSet<String> =
6092 ["active/repo-a".to_string(), "active/repo-b".to_string()]
6093 .into_iter()
6094 .collect();
6095 let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
6096
6097 assert!(
6098 !by_repo.contains_key("active/repo-a"),
6099 "the current repo's own tasks must not be swept"
6100 );
6101 assert!(
6102 !by_repo.contains_key("active/repo-b"),
6103 "another active repo's tasks must not be swept during repo A's tick"
6104 );
6105 assert_eq!(
6106 by_repo
6107 .get("inactive/repo")
6108 .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
6109 Some(vec![orphaned_id]),
6110 "a genuinely inactive repo's tasks must still be swept"
6111 );
6112
6113 assert_eq!(
6115 store.get(repo_a_id).await.unwrap().status,
6116 TaskStatus::Routed
6117 );
6118 assert_eq!(
6119 store.get(repo_b_id).await.unwrap().status,
6120 TaskStatus::Routed
6121 );
6122 }
6123
6124 #[tokio::test]
6126 async fn global_dispatch_sweep_skips_no_agent_tasks() {
6127 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
6128
6129 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6130
6131 let id = store
6132 .upsert_external(&UpsertExternal {
6133 repo: "inactive/repo",
6134 ext_id: "99",
6135 title: "No-agent routed task",
6136 body: "",
6137 author: "user",
6138 url: "https://github.com/inactive/repo/issues/99",
6139 labels: &["no-agent".to_string()],
6140 origin: "github",
6141 })
6142 .await
6143 .unwrap();
6144 store
6145 .store_route(&StoreRoute {
6146 id,
6147 agent: "claude",
6148 model: Some("sonnet"),
6149 complexity: "medium",
6150 estimate: 0,
6151 reason: "test",
6152 profile: "{}",
6153 skills: "[]",
6154 })
6155 .await
6156 .unwrap();
6157 store.update_status(id, TaskStatus::Routed).await.unwrap();
6158
6159 let tmux = Arc::new(TmuxManager::new());
6164 let transport = Arc::new(Transport::new());
6165 let capture = Arc::new(CaptureService::new(transport));
6166 let semaphore = Arc::new(Semaphore::new(4));
6167 let (weight_tx, _weight_rx) = mpsc::channel(16);
6168 let dispatching = Arc::new(DashMap::new());
6169 let dispatch_mode = DispatchMode {
6170 is_degraded: false,
6171 healthy_agents: 1,
6172 threshold: 1,
6173 };
6174
6175 let active_repos: std::collections::HashSet<String> =
6176 ["active/repo".to_string()].into_iter().collect();
6177 dispatch_routed_tasks_global(
6178 &tmux,
6179 &capture,
6180 &semaphore,
6181 &weight_tx,
6182 dispatch_mode,
6183 &dispatching,
6184 &store,
6185 &std::collections::HashMap::new(),
6186 &active_repos,
6187 )
6188 .await
6189 .unwrap();
6190
6191 assert_eq!(
6192 store.get(id).await.unwrap().status,
6193 TaskStatus::Routed,
6194 "no-agent tasks must not be dispatched"
6195 );
6196 }
6197}