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 let mut dispatchable = dispatchable;
2059 crate::engine::priority::sort_by_priority(&mut dispatchable, |t| &t.labels);
2060
2061 for (idx, task) in dispatchable.into_iter().enumerate() {
2062 if crate::engine::dependencies::dispatch_is_waiting(store, repo, &task.id.0).await {
2063 continue;
2064 }
2065 if idx > 0 && dispatch_mode.is_degraded {
2067 let delay_ms = sequential_delay;
2068 tracing::debug!(
2069 task_id = task.id.0,
2070 delay_ms = delay_ms,
2071 "sequential dispatch: waiting before dispatch"
2072 );
2073 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
2074 }
2075 let dispatch_key = format!("{}/{}", repo, task.id.0);
2082 {
2087 use dashmap::mapref::entry::Entry;
2088 match dispatching.entry(dispatch_key.clone()) {
2089 Entry::Occupied(existing) => {
2090 let existing_id = existing.get().clone();
2091 drop(existing); if existing_id == task.id.0 {
2093 tracing::debug!(
2094 task_id = task.id.0,
2095 "task already dispatching, skipping duplicate"
2096 );
2097 } else {
2098 tracing::warn!(
2099 task_id = task.id.0,
2100 existing_task_id = existing_id,
2101 dispatch_key,
2102 "dispatch key collision: unexpected task already holds this key"
2103 );
2104 }
2105 continue;
2106 }
2107 Entry::Vacant(slot) => {
2108 slot.insert(task.id.0.clone());
2109 tracing::info!(task_id = task.id.0, dispatch_key, "dispatch guard claimed");
2116 }
2117 }
2118 }
2119 let dispatch_guard =
2121 DispatchGuard::new(std::sync::Arc::clone(dispatching), dispatch_key.clone());
2122 let task_id = task.id.0.clone();
2123
2124 let route_result = match get_route_result(store, repo, &task_id).await {
2127 Ok(r) => Some(r),
2128 Err(RouteResultError::NoAgent { .. }) => {
2129 tracing::warn!(
2130 task_id,
2131 "routed task missing agent — resetting to new for re-routing (#1604)"
2132 );
2133 if let Err(e2) = task_manager.update_task_status(&task.id, Status::New).await {
2134 tracing::error!(task_id, error = %e2, "failed to reset task to new");
2135 }
2136 continue; }
2138 Err(e) => {
2139 tracing::warn!(task_id, error = %e, "get_route_result failed — dispatching without route info");
2140 None
2141 }
2142 };
2143
2144 if let Some(route) = route_result.as_ref() {
2150 let agent_cooled = is_agent_in_cooldown(&route.agent);
2151 let model_cooled = route
2152 .model
2153 .as_deref()
2154 .is_some_and(|model| is_model_in_cooldown(&route.agent, model));
2155 if agent_cooled || model_cooled {
2156 tracing::warn!(
2157 task_id,
2158 agent = route.agent,
2159 model = route.model.as_deref().unwrap_or(""),
2160 "routed agent/model entered cooldown after routing — deferring dispatch to next tick"
2161 );
2162 continue; }
2164 }
2165
2166 let session_name = tmux.session_name(repo, &task_id);
2168 if tmux
2169 .session_blocks_dispatch_from_map(&session_name, session_map)
2170 .await
2171 {
2172 tracing::warn!(
2173 task_id,
2174 session_name,
2175 "task has existing tmux session, skipping dispatch"
2176 );
2177 continue; }
2179
2180 let priority = crate::engine::priority::Priority::from_labels(&task.labels);
2182 let Some(permit) = crate::engine::priority::acquire_dispatch_slot(semaphore, priority)
2183 else {
2184 tracing::debug!("all parallel slots busy, skipping remaining tasks");
2185 break; };
2187
2188 let set_in_progress_result = task_manager
2190 .update_task_status(&task.id, Status::InProgress)
2191 .await;
2192 if let Err(e) = set_in_progress_result {
2193 tracing::error!(task_id, ?e, "failed to set in_progress, skipping dispatch");
2194 drop(permit);
2195 continue; }
2197 tracing::info!(task_id, "dispatching task");
2198
2199 capture
2201 .register_session(repo, &task_id, &session_name)
2202 .await;
2203
2204 let runner = std::sync::Arc::clone(runner);
2206 let backend = std::sync::Arc::clone(backend);
2207 let tmux = std::sync::Arc::clone(tmux);
2208 let capture = std::sync::Arc::clone(capture);
2209 let task_id_for_cleanup = task_id.clone();
2210 let task_owned = task;
2211 let weight_tx = weight_tx.clone();
2212 let repo_owned = repo.to_string();
2213 let task_manager_for_spawn = std::sync::Arc::clone(task_manager);
2214 let store_for_spawn = std::sync::Arc::clone(store);
2215
2216 let repo_ctx = repo_owned.clone();
2217 tokio::spawn(REPO_CONTEXT.scope(repo_ctx, async move {
2218 let _dispatch_guard = dispatch_guard; let dispatch_start = std::time::Instant::now();
2220 match runner
2221 .run_with_context(&task_owned, &backend, &tmux, route_result.as_ref())
2222 .await
2223 {
2224 Ok(signal) => {
2225 tracing::info!(task_id, "task runner completed");
2226
2227 let duration = dispatch_start.elapsed().as_secs_f64();
2228
2229 let enable_review = config::get("workflow.enable_review_agent")
2235 .map_or(true, |v| v != "false");
2236 let display_status = match &signal {
2237 WeightSignal::Success { .. } if enable_review => {
2238 let has_pr = match store_for_spawn
2242 .resolve_task_id(&repo_owned, &task_id)
2243 .await
2244 {
2245 Ok(Some(store_id)) => match store_for_spawn
2246 .get(store_id)
2247 .await
2248 {
2249 Ok(t) => t.pr_number.is_some(),
2250 Err(e) => {
2251 tracing::warn!(task_id, err = %e, "store.get() failed — defaulting to needs_review");
2252 true
2253 }
2254 },
2255 Ok(None) => false,
2256 Err(e) => {
2257 tracing::warn!(task_id, err = %e, "failed to check PR status — defaulting to needs_review");
2258 true
2259 }
2260 };
2261 if has_pr {
2262 "needs_review"
2263 } else {
2264 "done"
2265 }
2266 }
2267 WeightSignal::RateLimited { .. } | WeightSignal::Rerouted => "new",
2272 WeightSignal::Blocked => "blocked",
2273 WeightSignal::None if enable_review => "needs_review",
2278 WeightSignal::Success { .. } | WeightSignal::None => "done",
2279 };
2280
2281 let _ = weight_tx.send(signal).await;
2283
2284 if display_status == "needs_review" {
2288 if let Err(e) = task_manager_for_spawn
2291 .update_task_status_with_duration(
2292 &ExternalId(task_id.clone()),
2293 Status::NeedsReview,
2294 Some(duration),
2295 )
2296 .await
2297 {
2298 tracing::error!(task_id, err = %e, "update_task_status(NeedsReview) failed — task may be stuck");
2299 }
2300 } else {
2302 let final_status = match display_status {
2304 "done" => Status::Done,
2305 "blocked" => Status::Blocked,
2306 _ => Status::New,
2307 };
2308 if let Err(e) = task_manager_for_spawn
2309 .update_task_status_with_duration(
2310 &ExternalId(task_id.clone()),
2311 final_status,
2312 Some(duration),
2313 )
2314 .await
2315 {
2316 tracing::warn!(task_id, ?e, "failed to update task status after completion");
2317 }
2318 }
2319 }
2320 Err(e) => {
2321 tracing::error!(task_id, ?e, "task runner failed");
2322 let duration = dispatch_start.elapsed().as_secs_f64();
2323 if is_internal_id(&task_id) {
2324 if let Err(ue) = task_manager_for_spawn
2326 .update_task_status_with_duration(
2327 &ExternalId(task_id.clone()),
2328 Status::NeedsReview,
2329 Some(duration),
2330 )
2331 .await
2332 {
2333 tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
2334 }
2335 } else {
2336 if let Err(comment_err) = backend
2338 .post_comment(
2339 &ExternalId(task_id.clone()),
2340 &format!(
2341 "[{}] error: task runner failed: {e}{}",
2342 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
2343 crate::engine::orch_footer()
2344 ),
2345 )
2346 .await
2347 {
2348 tracing::warn!(
2349 task_id,
2350 ?comment_err,
2351 "failed to post error comment to GitHub"
2352 );
2353 }
2354 if let Err(ue) = task_manager_for_spawn
2358 .update_task_status_with_duration(
2359 &ExternalId(task_id.clone()),
2360 Status::NeedsReview,
2361 Some(duration),
2362 )
2363 .await
2364 {
2365 tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
2366 }
2367 }
2368 }
2369 }
2370
2371 capture.unregister_session(&repo_owned, &task_id_for_cleanup).await;
2373
2374 drop(permit);
2376 }));
2378 }
2379 Ok(())
2380}
2381
2382fn skips_agent(labels: &[String]) -> bool {
2383 labels
2384 .iter()
2385 .any(|label| label == "no-agent" || label == "owner:human")
2386}
2387
2388fn group_orphaned_routed_tasks(
2395 all_routed: Vec<crate::store::Task>,
2396 active_repos: &std::collections::HashSet<String>,
2397) -> std::collections::HashMap<String, Vec<crate::store::Task>> {
2398 let mut by_repo: std::collections::HashMap<String, Vec<crate::store::Task>> =
2399 std::collections::HashMap::new();
2400 for task in all_routed
2401 .into_iter()
2402 .filter(|t| !active_repos.contains(&t.repo))
2403 .filter(|t| !skips_agent(&t.labels))
2404 {
2405 by_repo.entry(task.repo.clone()).or_default().push(task);
2406 }
2407 by_repo
2408}
2409
2410#[expect(
2429 clippy::too_many_arguments,
2430 reason = "each argument is engine state the tick passes down"
2431)]
2432pub(crate) async fn dispatch_routed_tasks_global(
2433 tmux: &Arc<TmuxManager>,
2434 capture: &Arc<CaptureService>,
2435 semaphore: &Arc<Semaphore>,
2436 weight_tx: &mpsc::Sender<WeightSignal>,
2437 dispatch_mode: DispatchMode,
2438 dispatching: &Arc<DashMap<String, String>>,
2439 store: &Arc<TaskStore>,
2440 session_map: &std::collections::HashMap<String, bool>,
2441 active_repos: &std::collections::HashSet<String>,
2442) -> anyhow::Result<()> {
2443 use crate::store::TaskStatus;
2444
2445 let all_routed = store.list_all_by_status_global(TaskStatus::Routed).await?;
2446 let by_repo = group_orphaned_routed_tasks(all_routed, active_repos);
2447
2448 if by_repo.is_empty() {
2449 return Ok(());
2450 }
2451
2452 tracing::info!(
2453 repos = by_repo.len(),
2454 "global dispatch sweep: found routed tasks from inactive repos"
2455 );
2456
2457 for (repo, tasks) in by_repo {
2458 let backend: Arc<dyn ExternalBackend> = match crate::backends::github::GitHubBackend::new(
2459 repo.clone(),
2460 ) {
2461 Ok(b) => Arc::new(b),
2462 Err(e) => {
2463 tracing::warn!(repo = %repo, err = %e, "global sweep: failed to construct backend, skipping repo");
2464 continue;
2465 }
2466 };
2467 let task_manager = Arc::new(TaskManager::with_store(
2468 std::sync::Arc::clone(&backend),
2469 std::sync::Arc::clone(store),
2470 repo.clone(),
2471 ));
2472 let runner =
2473 Arc::new(TaskRunner::new(repo.clone()).with_store(std::sync::Arc::clone(store)));
2474 let dispatchable: Vec<ExternalTask> = tasks
2475 .iter()
2476 .map(crate::engine::tasks::store_task_to_external)
2477 .collect();
2478
2479 if let Err(e) = dispatch_tasks_for_repo(
2480 &backend,
2481 tmux,
2482 &repo,
2483 &runner,
2484 capture,
2485 semaphore,
2486 &task_manager,
2487 weight_tx,
2488 dispatch_mode,
2489 dispatching,
2490 store,
2491 session_map,
2492 dispatchable,
2493 )
2494 .await
2495 {
2496 tracing::warn!(repo = %repo, err = %e, "global sweep: dispatch failed for repo");
2497 }
2498 }
2499
2500 Ok(())
2501}
2502
2503pub(crate) async fn tick_unblock_parents(
2505 backend: &Arc<dyn ExternalBackend>,
2506 task_manager: &Arc<TaskManager>,
2507 store: &Arc<TaskStore>,
2508 repo: &str,
2509) -> anyhow::Result<()> {
2510 let blocked = task_manager.list_all_by_status(Status::Blocked).await?;
2511
2512 let store_blocked = store
2516 .list_by_status(repo, crate::store::TaskStatus::Blocked)
2517 .await?;
2518 let has_block_reason: std::collections::HashSet<String> = store_blocked
2519 .iter()
2520 .filter(|t| t.block_reason.is_some())
2521 .filter_map(|t| t.external_id.clone())
2522 .collect();
2523
2524 let handles: Vec<_> = blocked
2528 .into_iter()
2529 .filter(|task| {
2530 if has_block_reason.contains(&task.id.0) {
2531 tracing::debug!(
2532 task_id = task.id.0,
2533 "skipping blocked task with known block_reason"
2534 );
2535 false
2536 } else {
2537 true
2538 }
2539 })
2540 .map(|task| {
2541 let backend_clone = Arc::clone(backend);
2542 let task_manager_clone = Arc::clone(task_manager);
2543 let store_clone = Arc::clone(store);
2544 let repo_owned = repo.to_string();
2545 tokio::spawn(async move {
2546 let task_id = &task.id;
2547
2548 let mut children: Vec<ExternalId> = Vec::new();
2549 let mut child_statuses: std::collections::HashMap<
2550 String,
2551 crate::store::TaskStatus,
2552 > = std::collections::HashMap::new();
2553
2554 match store_clone.resolve_task_id(&repo_owned, &task_id.0).await {
2555 Ok(Some(parent_store_id)) => {
2556 match store_clone.list_children(&repo_owned, parent_store_id).await {
2557 Ok(store_children) => {
2558 for child in store_children {
2559 let Some(child_external_id) = child.external_id else {
2560 continue;
2561 };
2562 child_statuses.insert(child_external_id.clone(), child.status);
2563 children.push(ExternalId(child_external_id));
2564 }
2565 }
2566 Err(e) => {
2567 tracing::debug!(
2568 task_id = task_id.0,
2569 ?e,
2570 "failed to list store-linked children"
2571 );
2572 }
2573 }
2574 }
2575 Ok(None) => {}
2576 Err(e) => {
2577 tracing::debug!(
2578 task_id = task_id.0,
2579 ?e,
2580 "failed to resolve blocked task in store for child lookup"
2581 );
2582 }
2583 }
2584
2585 if !is_internal_id(&task_id.0) {
2586 match backend_clone.get_sub_issues(task_id).await {
2587 Ok(ids) => {
2588 let mut seen: std::collections::HashSet<String> =
2589 children.iter().map(|child| child.0.clone()).collect();
2590 for id in ids {
2591 if seen.insert(id.0.clone()) {
2592 children.push(id);
2593 }
2594 }
2595 }
2596 Err(e) => {
2597 tracing::debug!(task_id = task_id.0, ?e, "failed to get sub-issues");
2598 }
2599 }
2600 }
2601
2602 if children.is_empty() {
2604 return;
2605 }
2606
2607 let mut all_done = true;
2610 let child_ext_strs: Vec<String> =
2612 children.iter().map(|c| c.0.clone()).collect();
2613 let child_exts: Vec<&str> =
2614 child_ext_strs.iter().map(std::string::String::as_str).collect();
2615 match store_clone
2617 .get_statuses_by_external_ids(&repo_owned, &child_exts)
2618 .await
2619 { Ok(map) => {
2620 for (k, v) in map {
2621 child_statuses.insert(k, v);
2622 }
2623 } _ => {
2624 tracing::debug!(
2625 task_id = task_id.0,
2626 "failed to batch-query store for child statuses; falling back to per-child lookups"
2627 );
2628 }}
2629
2630 for child_id in &children {
2631 if let Some(status) = child_statuses.get(&child_id.0) {
2633 if *status != crate::store::TaskStatus::Done {
2634 all_done = false;
2635 break;
2636 }
2637 continue;
2638 }
2639
2640 match backend_clone.get_task(child_id).await {
2642 Ok(child) => {
2643 if !child.labels.iter().any(|l| l == Status::Done.as_label()) {
2644 all_done = false;
2645 break;
2646 }
2647 }
2648 Err(e) => {
2649 tracing::debug!(task_id = task_id.0, child = %child_id.0, ?e, "failed to fetch child task from backend — treating as not done");
2650 all_done = false;
2651 break;
2652 }
2653 }
2654 }
2655
2656 if all_done {
2657 tracing::info!(
2658 task_id = task_id.0,
2659 children = children.len(),
2660 "all children done, unblocking parent"
2661 );
2662 if let Err(e) =
2663 task_manager_clone.update_task_status(task_id, Status::New).await
2664 {
2665 tracing::warn!(task_id = task_id.0, ?e, "failed to unblock parent");
2666 }
2667 }
2668 })
2669 })
2670 .collect();
2671
2672 for handle in handles {
2673 if let Err(e) = handle.await {
2674 tracing::warn!(?e, "phase 4 sub-issue check panicked");
2675 }
2676 }
2677 Ok(())
2678}
2679
2680pub(crate) async fn tick_job_scheduler(
2682 jobs_path: &std::path::Path,
2683 backend: &Arc<dyn ExternalBackend>,
2684 store: Option<&Arc<TaskStore>>,
2685 repo: &str,
2686 transport: Option<&Arc<crate::channels::transport::Transport>>,
2687) -> anyhow::Result<()> {
2688 jobs::tick(jobs_path, backend, store, repo, transport).await
2689}
2690
2691#[expect(
2701 clippy::too_many_arguments,
2702 reason = "each argument is engine state the tick passes down"
2703)]
2704pub(crate) async fn tick_before_routing(
2705 backend: &Arc<dyn ExternalBackend>,
2706 tmux: &Arc<TmuxManager>,
2707 repo: &str,
2708 capture: &Arc<CaptureService>,
2709 config: &EngineConfig,
2710 task_manager: &Arc<TaskManager>,
2711 dispatching: &Arc<DashMap<String, String>>,
2712 store: &Arc<TaskStore>,
2713 session_map: &std::collections::HashMap<String, bool>,
2714) {
2715 let mut phases: Vec<(&str, u128)> = Vec::new();
2716 let mut at = Instant::now();
2717
2718 if let Err(e) = tick_check_session_completions(tmux, repo, capture, store).await {
2719 tracing::warn!(err = %e, "session completion phase failed");
2720 }
2721 phases.push(("sessions", at.elapsed().as_millis()));
2722
2723 if !store.is_leader() {
2726 super::log_phase_timings("tick.pre_routing", repo, &phases);
2727 return;
2728 }
2729
2730 at = Instant::now();
2731 if let Err(e) =
2732 tick_detect_silent_agents(tmux, repo, capture, backend, task_manager, config, store).await
2733 {
2734 tracing::warn!(err = %e, "silent-agent detection phase failed");
2735 }
2736 phases.push(("silent_agents", at.elapsed().as_millis()));
2737
2738 at = Instant::now();
2739 if let Err(e) = tick_recover_stuck_tasks(
2740 backend,
2741 tmux,
2742 repo,
2743 task_manager,
2744 config,
2745 store,
2746 session_map,
2747 dispatching,
2748 )
2749 .await
2750 {
2751 tracing::warn!(err = %e, "stuck-task recovery phase failed");
2752 }
2753 phases.push(("stuck_tasks", at.elapsed().as_millis()));
2754
2755 super::log_phase_timings("tick.pre_routing", repo, &phases);
2756}
2757
2758#[expect(
2766 clippy::too_many_arguments,
2767 reason = "each argument is engine state the tick passes down"
2768)]
2769pub(crate) async fn tick_after_routing(
2770 backend: &Arc<dyn ExternalBackend>,
2771 tmux: &Arc<TmuxManager>,
2772 repo: &str,
2773 runner: &Arc<TaskRunner>,
2774 capture: &Arc<CaptureService>,
2775 semaphore: &Arc<Semaphore>,
2776 project_dir: &std::path::Path,
2777 task_manager: &Arc<TaskManager>,
2778 weight_tx: &mpsc::Sender<WeightSignal>,
2779 dispatch_mode: DispatchMode,
2780 dispatching: &Arc<DashMap<String, String>>,
2781 store: &Arc<TaskStore>,
2782 transport: Option<&Arc<crate::channels::transport::Transport>>,
2783 session_map: &std::collections::HashMap<String, bool>,
2784) {
2785 let mut phases: Vec<(&str, u128)> = Vec::new();
2786 let mut at = Instant::now();
2787
2788 if let Err(e) = tick_dispatch_tasks(
2789 backend,
2790 tmux,
2791 repo,
2792 runner,
2793 capture,
2794 semaphore,
2795 task_manager,
2796 weight_tx,
2797 dispatch_mode,
2798 dispatching,
2799 store,
2800 session_map,
2801 )
2802 .await
2803 {
2804 tracing::warn!(err = %e, "dispatch phase failed");
2805 }
2806 phases.push(("dispatch", at.elapsed().as_millis()));
2807
2808 at = Instant::now();
2809 if let Err(e) = tick_unblock_parents(backend, task_manager, store, repo).await {
2810 tracing::warn!(err = %e, "parent-unblock phase failed");
2811 }
2812 phases.push(("unblock_parents", at.elapsed().as_millis()));
2813
2814 at = Instant::now();
2815 let jobs_path = project_dir.join(".orch.yml");
2816 if let Err(e) = tick_job_scheduler(&jobs_path, backend, Some(store), repo, transport).await {
2817 tracing::error!(?e, "job scheduler tick failed");
2818 }
2819 phases.push(("jobs", at.elapsed().as_millis()));
2820
2821 super::log_phase_timings("tick.post_routing", repo, &phases);
2822}
2823
2824#[expect(
2834 clippy::too_many_arguments,
2835 reason = "each argument is engine state the tick passes down"
2836)]
2837pub(crate) async fn tick_all(
2838 engines: &[ProjectEngine],
2839 tmux: &Arc<TmuxManager>,
2840 capture: &Arc<CaptureService>,
2841 semaphore: &Arc<Semaphore>,
2842 config: &EngineConfig,
2843 router: &Arc<tokio::sync::RwLock<Router>>,
2844 weight_tx: &mpsc::Sender<WeightSignal>,
2845 dispatching: &Arc<DashMap<String, String>>,
2846 transport: Option<&Arc<crate::channels::transport::Transport>>,
2847 active_repos: &std::collections::HashSet<String>,
2848 auto_merge_in_flight: &Arc<DashSet<String>>,
2849) {
2850 let _tick_span = tracing::info_span!("engine.tick").entered();
2851
2852 let Some(first) = engines.first() else {
2853 return;
2854 };
2855 let store = &first.store;
2858
2859 for (project, call_type, calls) in crate::github::http::drain_call_counts()
2861 .into_iter()
2862 .take(10)
2863 {
2864 tracing::debug!(
2865 project,
2866 call_type,
2867 calls,
2868 "github api calls since last tick"
2869 );
2870 }
2871
2872 let session_map = tmux.batch_session_active().await;
2874
2875 let mut passes = Vec::with_capacity(engines.len());
2876 for engine in engines {
2877 passes.push(REPO_CONTEXT.scope(
2878 engine.repo.clone(),
2879 tick_before_routing(
2880 &engine.backend,
2881 tmux,
2882 &engine.repo,
2883 capture,
2884 config,
2885 &engine.task_manager,
2886 dispatching,
2887 &engine.store,
2888 &session_map,
2889 ),
2890 ));
2891 }
2892 futures::future::join_all(passes).await;
2893
2894 if !store.is_leader() {
2895 return;
2896 }
2897
2898 let at = Instant::now();
2902 {
2903 let router_guard = router.read().await;
2904 tick_global_sweeps(
2905 &first.task_manager,
2906 store,
2907 &router_guard,
2908 dispatching,
2909 auto_merge_in_flight,
2910 )
2911 .await;
2912 }
2913 let sweeps_ms = at.elapsed().as_millis();
2914
2915 let at = Instant::now();
2916 recover_untracked_repo_stuck_tasks(
2917 tmux,
2918 config,
2919 store,
2920 &session_map,
2921 dispatching,
2922 active_repos,
2923 )
2924 .await;
2925 let untracked_ms = at.elapsed().as_millis();
2926
2927 let at = Instant::now();
2928 if let Err(err) = crate::engine::dependencies::reconcile_dependencies(
2929 first.backend.as_ref(),
2930 store,
2931 transport.map(std::convert::AsRef::as_ref),
2932 )
2933 .await
2934 {
2935 tracing::warn!(err = %err, "dependency reconcile failed");
2936 }
2937 super::log_phase_timings(
2938 "tick.global",
2939 &first.repo,
2940 &[
2941 ("global_sweeps", sweeps_ms),
2942 ("untracked_stuck", untracked_ms),
2943 ("dependencies", at.elapsed().as_millis()),
2944 ],
2945 );
2946
2947 {
2948 let mut router_guard = router.write().await;
2949 let mut routing_quota =
2950 RoutingTickQuota::new(crate::engine::router::config::max_tasks_per_routing_tick());
2951 for engine in engines {
2952 let at = Instant::now();
2953 REPO_CONTEXT
2954 .scope(engine.repo.clone(), async {
2955 if let Err(e) = tick_route_tasks(
2956 &engine.backend,
2957 &engine.task_manager,
2958 &mut router_guard,
2959 &engine.store,
2960 &engine.repo,
2961 &mut routing_quota,
2962 )
2963 .await
2964 {
2965 tracing::warn!(err = %e, "routing phase failed");
2966 }
2967 })
2968 .await;
2969 super::log_phase_timings(
2970 "tick.routing",
2971 &engine.repo,
2972 &[("route", at.elapsed().as_millis())],
2973 );
2974 }
2975
2976 if let Err(e) =
2980 route_new_tasks_global(&mut router_guard, store, active_repos, &mut routing_quota).await
2981 {
2982 tracing::warn!(err = %e, "global new-task routing sweep failed");
2983 }
2984 }
2985
2986 let dispatch_mode = dispatch_mode_from_router(&*router.read().await);
2987
2988 let session_map = tmux.batch_session_active().await;
2991
2992 let mut passes = Vec::with_capacity(engines.len());
2993 for engine in engines {
2994 passes.push(REPO_CONTEXT.scope(
2995 engine.repo.clone(),
2996 tick_after_routing(
2997 &engine.backend,
2998 tmux,
2999 &engine.repo,
3000 &engine.runner,
3001 capture,
3002 semaphore,
3003 &engine.project_dir,
3004 &engine.task_manager,
3005 weight_tx,
3006 dispatch_mode,
3007 dispatching,
3008 &engine.store,
3009 transport,
3010 &session_map,
3011 ),
3012 ));
3013 }
3014 futures::future::join_all(passes).await;
3015
3016 if let Err(e) = dispatch_routed_tasks_global(
3022 tmux,
3023 capture,
3024 semaphore,
3025 weight_tx,
3026 dispatch_mode,
3027 dispatching,
3028 store,
3029 &session_map,
3030 active_repos,
3031 )
3032 .await
3033 {
3034 tracing::warn!(err = %e, "global routed-task dispatch sweep failed");
3035 }
3036}
3037
3038#[cfg(test)]
3039mod tests {
3040 use super::*;
3041 use crate::backends::{ExternalId, ExternalTask, Mention, Status};
3042 use crate::channels::transport::Transport;
3043 use crate::engine::tasks::TaskManager;
3044 use crate::store::TaskStore;
3045 use crate::tmux::{SessionGuard, TmuxManager};
3046 use async_trait::async_trait;
3047 use std::sync::{Arc, Mutex};
3048
3049 #[test]
3050 fn skips_agent_for_human_and_no_agent_labels() {
3051 assert!(skips_agent(&["owner:human".to_string()]));
3052 assert!(skips_agent(&["no-agent".to_string()]));
3053 assert!(!skips_agent(&["status:new".to_string()]));
3054 }
3055
3056 fn cooled_review_router_for_test() -> Router {
3060 Router::new_for_test(
3061 crate::engine::router::RouterConfig::default(),
3062 vec!["claude".to_string()],
3063 )
3064 }
3065
3066 struct MockBackend {
3070 blocked_tasks: Vec<ExternalTask>,
3072 sub_issues: std::collections::HashMap<String, Vec<ExternalId>>,
3074 tasks_by_id: std::collections::HashMap<String, ExternalTask>,
3076 get_sub_issues_calls: Arc<Mutex<Vec<String>>>,
3078 status_updates: Arc<Mutex<Vec<(String, Status)>>>,
3080 removed_labels: Arc<Mutex<Vec<(String, String)>>>,
3082 posted_comments: Arc<Mutex<Vec<(String, String)>>>,
3084 }
3085
3086 impl MockBackend {
3087 fn new() -> Self {
3088 Self {
3089 blocked_tasks: vec![],
3090 sub_issues: std::collections::HashMap::new(),
3091 tasks_by_id: std::collections::HashMap::new(),
3092 get_sub_issues_calls: Arc::new(Mutex::new(vec![])),
3093 status_updates: Arc::new(Mutex::new(vec![])),
3094 removed_labels: Arc::new(Mutex::new(vec![])),
3095 posted_comments: Arc::new(Mutex::new(vec![])),
3096 }
3097 }
3098 }
3099
3100 fn make_task(id: &str, labels: &[&str]) -> ExternalTask {
3101 ExternalTask {
3102 id: ExternalId(id.to_string()),
3103 title: format!("Task {id}"),
3104 body: String::new(),
3105 state: "open".to_string(),
3106 labels: labels
3107 .iter()
3108 .map(std::string::ToString::to_string)
3109 .collect(),
3110 author: "bot".to_string(),
3111 created_at: "2026-01-01T00:00:00Z".to_string(),
3112 updated_at: "2026-01-01T00:00:00Z".to_string(),
3113 url: format!("https://github.com/test/test/issues/{id}"),
3114 }
3115 }
3116
3117 #[async_trait]
3118 impl ExternalBackend for MockBackend {
3119 fn name(&self) -> &'static str {
3120 "mock"
3121 }
3122 async fn create_task(
3123 &self,
3124 _title: &str,
3125 _body: &str,
3126 _labels: &[String],
3127 ) -> anyhow::Result<ExternalId> {
3128 Ok(ExternalId("new".to_string()))
3129 }
3130 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
3131 self.tasks_by_id
3132 .get(&id.0)
3133 .cloned()
3134 .ok_or_else(|| anyhow::anyhow!("task not found: {}", id.0))
3135 }
3136 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
3137 if status == Status::Blocked {
3138 Ok(self.blocked_tasks.clone())
3139 } else {
3140 Ok(vec![])
3141 }
3142 }
3143 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
3144 Ok(vec![])
3145 }
3146 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
3147 self.posted_comments
3148 .lock()
3149 .unwrap()
3150 .push((_id.0.clone(), _body.to_string()));
3151 Ok(())
3152 }
3153 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
3154 Ok(())
3155 }
3156 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
3157 self.removed_labels
3158 .lock()
3159 .unwrap()
3160 .push((_id.0.clone(), _label.to_string()));
3161 Ok(())
3162 }
3163 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
3164 self.get_sub_issues_calls.lock().unwrap().push(id.0.clone());
3165 Ok(self.sub_issues.get(&id.0).cloned().unwrap_or_default())
3166 }
3167 async fn create_sub_task(
3168 &self,
3169 _parent: &ExternalId,
3170 _title: &str,
3171 _body: &str,
3172 _labels: &[String],
3173 ) -> anyhow::Result<ExternalId> {
3174 Ok(ExternalId("child".to_string()))
3175 }
3176 async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
3177 Ok(())
3178 }
3179 async fn has_open_issue_with_title(
3180 &self,
3181 _title: &str,
3182 _label: &str,
3183 ) -> anyhow::Result<bool> {
3184 Ok(false)
3185 }
3186 async fn health_check(&self) -> anyhow::Result<()> {
3187 Ok(())
3188 }
3189 async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
3190 Ok(false)
3191 }
3192 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
3193 Ok(Some("testbot".to_string()))
3194 }
3195 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
3196 Ok(vec![])
3197 }
3198 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
3199 self.status_updates
3200 .lock()
3201 .unwrap()
3202 .push((id.0.clone(), status));
3203 Ok(())
3204 }
3205 }
3206
3207 fn make_task_manager(backend: Arc<dyn ExternalBackend>) -> Arc<TaskManager> {
3208 Arc::new(TaskManager::new(backend))
3209 }
3210
3211 #[tokio::test]
3214 async fn unblock_parents_unblocks_when_all_children_done() {
3215 let mut mock = MockBackend::new();
3216
3217 let parent = make_task("10", &["status:blocked"]);
3219 mock.blocked_tasks.push(parent.clone());
3220 mock.sub_issues.insert(
3221 "10".to_string(),
3222 vec![ExternalId("11".to_string()), ExternalId("12".to_string())],
3223 );
3224 mock.tasks_by_id
3225 .insert("11".to_string(), make_task("11", &["status:done"]));
3226 mock.tasks_by_id
3227 .insert("12".to_string(), make_task("12", &["status:done"]));
3228
3229 let status_updates = std::sync::Arc::clone(&mock.status_updates);
3230 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3231 let task_manager = make_task_manager(std::sync::Arc::clone(&backend));
3232 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3233
3234 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3235 .await
3236 .unwrap();
3237
3238 let updates = status_updates.lock().unwrap();
3239 assert_eq!(updates.len(), 1, "parent should be unblocked");
3240 assert_eq!(updates[0], ("10".to_string(), Status::New));
3241 }
3242
3243 #[tokio::test]
3244 async fn unblock_parents_skips_when_child_not_done() {
3245 let mut mock = MockBackend::new();
3246
3247 let parent = make_task("20", &["status:blocked"]);
3248 mock.blocked_tasks.push(parent.clone());
3249 mock.sub_issues.insert(
3250 "20".to_string(),
3251 vec![ExternalId("21".to_string()), ExternalId("22".to_string())],
3252 );
3253 mock.tasks_by_id
3255 .insert("21".to_string(), make_task("21", &["status:done"]));
3256 mock.tasks_by_id
3257 .insert("22".to_string(), make_task("22", &["status:in_progress"]));
3258
3259 let status_updates = std::sync::Arc::clone(&mock.status_updates);
3260 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3261 let task_manager = make_task_manager(std::sync::Arc::clone(&backend));
3262 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3263
3264 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3265 .await
3266 .unwrap();
3267
3268 let updates = status_updates.lock().unwrap();
3269 assert!(
3270 updates.is_empty(),
3271 "should not unblock when a child is still running"
3272 );
3273 }
3274
3275 #[tokio::test]
3276 async fn unblock_parents_skips_task_with_no_children() {
3277 let mut mock = MockBackend::new();
3278
3279 let parent = make_task("30", &["status:blocked"]);
3281 mock.blocked_tasks.push(parent.clone());
3282 let status_updates = std::sync::Arc::clone(&mock.status_updates);
3285 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3286 let task_manager = make_task_manager(std::sync::Arc::clone(&backend));
3287 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3288
3289 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3290 .await
3291 .unwrap();
3292
3293 let updates = status_updates.lock().unwrap();
3294 assert!(
3295 updates.is_empty(),
3296 "should not unblock tasks with no sub-issues"
3297 );
3298 }
3299
3300 #[tokio::test]
3301 async fn unblock_parents_skips_task_with_block_reason_in_store() {
3302 let mut mock = MockBackend::new();
3305
3306 let parent = make_task("60", &["status:blocked"]);
3308 mock.blocked_tasks.push(parent.clone());
3309 mock.sub_issues
3312 .insert("60".to_string(), vec![ExternalId("61".to_string())]);
3313 mock.tasks_by_id
3314 .insert("61".to_string(), make_task("61", &["status:done"]));
3315
3316 let status_updates = std::sync::Arc::clone(&mock.status_updates);
3317 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3318 let task_manager = make_task_manager(std::sync::Arc::clone(&backend));
3319
3320 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3322 let task_id = store
3323 .upsert_external(&crate::store::UpsertExternal {
3324 repo: "test/repo",
3325 ext_id: "60",
3326 title: "Task 60",
3327 body: "",
3328 author: "bot",
3329 url: "https://github.com/test/test/issues/60",
3330 labels: &[],
3331 origin: "github",
3332 })
3333 .await
3334 .unwrap();
3335 store
3336 .update_status(task_id, crate::store::TaskStatus::Blocked)
3337 .await
3338 .unwrap();
3339 store
3340 .set_block_reason(task_id, Some("CI failure limit reached"))
3341 .await
3342 .unwrap();
3343
3344 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3345 .await
3346 .unwrap();
3347
3348 let updates = status_updates.lock().unwrap();
3349 assert!(
3350 updates.is_empty(),
3351 "task with block_reason should be skipped — no API call, no unblock"
3352 );
3353 }
3354
3355 #[tokio::test]
3356 async fn unblock_parents_unblocks_internal_parent_from_store_children() {
3357 let mock = MockBackend::new();
3358 let get_sub_issues_calls = std::sync::Arc::clone(&mock.get_sub_issues_calls);
3359 let status_updates = std::sync::Arc::clone(&mock.status_updates);
3360 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3361 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3362 let task_manager = Arc::new(TaskManager::with_store(
3363 std::sync::Arc::clone(&backend),
3364 std::sync::Arc::clone(&store),
3365 "test/repo".to_string(),
3366 ));
3367
3368 let parent_id = store
3369 .create_internal("test/repo", "Parent", "", "job", "daily", None)
3370 .await
3371 .unwrap();
3372 store
3373 .update_status(parent_id, crate::store::TaskStatus::Blocked)
3374 .await
3375 .unwrap();
3376 let child_id = store
3377 .create(&crate::store::NewTask {
3378 external_id: Some("91".to_string()),
3379 repo: "test/repo".to_string(),
3380 origin: "github".to_string(),
3381 title: "Child".to_string(),
3382 body: String::new(),
3383 source: String::new(),
3384 source_id: String::new(),
3385 author: String::new(),
3386 url: String::new(),
3387 labels: vec![],
3388 parent_id: Some(parent_id),
3389 })
3390 .await
3391 .unwrap();
3392 store
3393 .update_status(child_id, crate::store::TaskStatus::Done)
3394 .await
3395 .unwrap();
3396
3397 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3398 .await
3399 .unwrap();
3400
3401 let parent = store.get(parent_id).await.unwrap();
3402 assert_eq!(parent.status, crate::store::TaskStatus::New);
3403 assert!(
3404 status_updates.lock().unwrap().is_empty(),
3405 "internal parent should be updated in store without backend mirroring"
3406 );
3407 assert!(
3408 get_sub_issues_calls.lock().unwrap().is_empty(),
3409 "internal parent should not query backend sub-issues"
3410 );
3411 }
3412
3413 #[cfg(test)]
3417 async fn set_task_updated_at_past(store: &TaskStore, task_id: i64) {
3418 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
3419 .bind(task_id)
3420 .execute(store.pool())
3421 .await
3422 .unwrap();
3423 }
3424
3425 async fn set_in_review_entered_at_past(store: &TaskStore, task_id: i64) {
3427 sqlx::query(
3428 "UPDATE task_activity SET timestamp = '2020-01-01T00:00:00Z'
3429 WHERE task_id = ? AND to_status = 'in_review'",
3430 )
3431 .bind(task_id)
3432 .execute(store.pool())
3433 .await
3434 .unwrap();
3435 }
3436
3437 #[tokio::test]
3438 async fn recover_stuck_tasks_resets_external_in_review_to_needs_review() {
3439 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3440 let mock = MockBackend::new();
3441 let status_updates = std::sync::Arc::clone(&mock.status_updates);
3442 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3443 let task_manager = Arc::new(TaskManager::with_store(
3444 std::sync::Arc::clone(&backend),
3445 std::sync::Arc::clone(&store),
3446 "owner/repo".to_string(),
3447 ));
3448 let tmux = Arc::new(TmuxManager::new());
3449 let config = EngineConfig {
3450 no_session_stuck_timeout: 600,
3451 stuck_timeout: 1800,
3452 ..EngineConfig::default()
3453 };
3454
3455 let id = store
3457 .upsert_external(&crate::store::UpsertExternal {
3458 repo: "owner/repo",
3459 ext_id: "99",
3460 title: "Stuck InReview",
3461 body: "",
3462 author: "",
3463 url: "",
3464 labels: &[],
3465 origin: "github",
3466 })
3467 .await
3468 .unwrap();
3469 store
3470 .update_status(id, crate::store::TaskStatus::InReview)
3471 .await
3472 .unwrap();
3473 set_in_review_entered_at_past(&store, id).await;
3474 set_task_updated_at_past(&store, id).await;
3475
3476 tick_recover_stuck_tasks(
3477 &backend,
3478 &tmux,
3479 "owner/repo",
3480 &task_manager,
3481 &config,
3482 &store,
3483 &std::collections::HashMap::new(),
3484 &Arc::new(DashMap::new()),
3485 )
3486 .await
3487 .unwrap();
3488
3489 let updates = status_updates.lock().unwrap();
3490 assert_eq!(updates.len(), 1, "stuck InReview task should be recovered");
3491 assert_eq!(updates[0].0, "99");
3492 assert_eq!(updates[0].1, Status::NeedsReview);
3493 }
3494
3495 #[tokio::test]
3496 async fn recover_stuck_tasks_skips_recent_in_review() {
3497 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3498 let mock = MockBackend::new();
3499 let status_updates = std::sync::Arc::clone(&mock.status_updates);
3500 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3501 let task_manager = Arc::new(TaskManager::with_store(
3502 std::sync::Arc::clone(&backend),
3503 std::sync::Arc::clone(&store),
3504 "owner/repo".to_string(),
3505 ));
3506 let tmux = Arc::new(TmuxManager::new());
3507 let config = EngineConfig {
3508 no_session_stuck_timeout: 600, stuck_timeout: 1800,
3510 ..EngineConfig::default()
3511 };
3512
3513 let id = store
3515 .upsert_external(&crate::store::UpsertExternal {
3516 repo: "owner/repo",
3517 ext_id: "100",
3518 title: "Recent InReview",
3519 body: "",
3520 author: "",
3521 url: "",
3522 labels: &[],
3523 origin: "github",
3524 })
3525 .await
3526 .unwrap();
3527 store
3528 .update_status(id, crate::store::TaskStatus::InReview)
3529 .await
3530 .unwrap();
3531 set_task_updated_at_past(&store, id).await;
3532
3533 tick_recover_stuck_tasks(
3534 &backend,
3535 &tmux,
3536 "owner/repo",
3537 &task_manager,
3538 &config,
3539 &store,
3540 &std::collections::HashMap::new(),
3541 &Arc::new(DashMap::new()),
3542 )
3543 .await
3544 .unwrap();
3545
3546 let updates = status_updates.lock().unwrap();
3547 assert!(
3548 updates.is_empty(),
3549 "recent InReview task should not be recovered"
3550 );
3551 }
3552
3553 #[tokio::test]
3554 async fn recover_stuck_tasks_resets_internal_in_review_to_needs_review() {
3555 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3556 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3557 let task_manager = Arc::new(TaskManager::with_store(
3558 std::sync::Arc::clone(&backend),
3559 std::sync::Arc::clone(&store),
3560 "owner/repo".to_string(),
3561 ));
3562 let tmux = Arc::new(TmuxManager::new());
3563 let config = EngineConfig {
3564 no_session_stuck_timeout: 600,
3565 stuck_timeout: 1800,
3566 ..EngineConfig::default()
3567 };
3568
3569 let id = store
3571 .create_internal("owner/repo", "Internal InReview", "", "cron", "1", None)
3572 .await
3573 .unwrap();
3574 store
3575 .update_status(id, crate::store::TaskStatus::InReview)
3576 .await
3577 .unwrap();
3578 set_in_review_entered_at_past(&store, id).await;
3579 set_task_updated_at_past(&store, id).await;
3580
3581 tick_recover_stuck_tasks(
3582 &backend,
3583 &tmux,
3584 "owner/repo",
3585 &task_manager,
3586 &config,
3587 &store,
3588 &std::collections::HashMap::new(),
3589 &Arc::new(DashMap::new()),
3590 )
3591 .await
3592 .unwrap();
3593
3594 let task = store.get(id).await.unwrap();
3595 assert_eq!(
3596 task.status,
3597 crate::store::TaskStatus::NeedsReview,
3598 "stuck internal InReview task should be reset to NeedsReview"
3599 );
3600 }
3601
3602 #[tokio::test]
3603 async fn recover_stuck_tasks_ages_in_review_from_status_entry_not_updated_at() {
3604 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3605 let mock = MockBackend::new();
3606 let status_updates = std::sync::Arc::clone(&mock.status_updates);
3607 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3608 let task_manager = Arc::new(TaskManager::with_store(
3609 std::sync::Arc::clone(&backend),
3610 std::sync::Arc::clone(&store),
3611 "owner/repo".to_string(),
3612 ));
3613 let tmux = Arc::new(TmuxManager::new());
3614 let config = EngineConfig {
3615 no_session_stuck_timeout: 600,
3616 stuck_timeout: 1800,
3617 ..EngineConfig::default()
3618 };
3619
3620 let id = store
3621 .upsert_external(&crate::store::UpsertExternal {
3622 repo: "owner/repo",
3623 ext_id: "102",
3624 title: "Fresh updated_at InReview",
3625 body: "",
3626 author: "",
3627 url: "",
3628 labels: &[],
3629 origin: "github",
3630 })
3631 .await
3632 .unwrap();
3633 store
3634 .update_status(id, crate::store::TaskStatus::InReview)
3635 .await
3636 .unwrap();
3637 set_in_review_entered_at_past(&store, id).await;
3638 store
3639 .set_fields(id, &[("pr_number", serde_json::json!(42))])
3640 .await
3641 .unwrap();
3642
3643 tick_recover_stuck_tasks(
3644 &backend,
3645 &tmux,
3646 "owner/repo",
3647 &task_manager,
3648 &config,
3649 &store,
3650 &std::collections::HashMap::new(),
3651 &Arc::new(DashMap::new()),
3652 )
3653 .await
3654 .unwrap();
3655
3656 let updates = status_updates.lock().unwrap();
3657 assert_eq!(
3658 updates.len(),
3659 1,
3660 "in_review stuck recovery must age from the status entry even when updated_at is fresh"
3661 );
3662 assert_eq!(updates[0].0, "102");
3663 assert_eq!(updates[0].1, Status::NeedsReview);
3664 }
3665
3666 async fn orphaned_review_status_after_recovery(
3669 with_decision: bool,
3670 ) -> crate::store::TaskStatus {
3671 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3672 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3673 let task_manager = Arc::new(TaskManager::with_store(
3674 std::sync::Arc::clone(&backend),
3675 std::sync::Arc::clone(&store),
3676 "owner/repo".to_string(),
3677 ));
3678 let tmux = Arc::new(TmuxManager::new());
3679
3680 let id = store
3681 .upsert_external(&crate::store::UpsertExternal {
3682 repo: "owner/repo",
3683 ext_id: "55",
3684 title: "Orphaned review",
3685 body: "",
3686 author: "",
3687 url: "",
3688 labels: &[],
3689 origin: "github",
3690 })
3691 .await
3692 .unwrap();
3693 store
3694 .update_status(id, crate::store::TaskStatus::InReview)
3695 .await
3696 .unwrap();
3697 set_in_review_entered_at_past(&store, id).await;
3699 if with_decision {
3700 crate::store::store_log_activity(
3701 &Some(std::sync::Arc::clone(&store)),
3702 "owner/repo",
3703 "55",
3704 "review_decision",
3705 None,
3706 None,
3707 None::<&str>,
3708 None::<&str>,
3709 Some(&serde_json::json!({"decision": "approve"})),
3710 )
3711 .await;
3712 }
3713
3714 tick_recover_stuck_tasks(
3715 &backend,
3716 &tmux,
3717 "owner/repo",
3718 &task_manager,
3719 &EngineConfig::default(),
3720 &store,
3721 &std::collections::HashMap::new(),
3722 &Arc::new(DashMap::new()),
3723 )
3724 .await
3725 .unwrap();
3726
3727 store.get(id).await.unwrap().status
3728 }
3729
3730 #[tokio::test]
3731 async fn stale_in_review_recovery_resets_orphaned_task_without_session() {
3732 assert_eq!(
3733 orphaned_review_status_after_recovery(false).await,
3734 crate::store::TaskStatus::NeedsReview
3735 );
3736 }
3737
3738 #[tokio::test]
3739 async fn stale_in_review_recovery_leaves_decided_task_waiting_on_merge() {
3740 assert_eq!(
3741 orphaned_review_status_after_recovery(true).await,
3742 crate::store::TaskStatus::InReview
3743 );
3744 }
3745
3746 #[tokio::test]
3747 async fn recover_stuck_tasks_resets_review_agent_failures() {
3748 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3749 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3750 let task_manager = Arc::new(TaskManager::with_store(
3751 std::sync::Arc::clone(&backend),
3752 std::sync::Arc::clone(&store),
3753 "owner/repo".to_string(),
3754 ));
3755 let tmux = Arc::new(TmuxManager::new());
3756
3757 let id = store
3758 .upsert_external(&crate::store::UpsertExternal {
3759 repo: "owner/repo",
3760 ext_id: "104",
3761 title: "Orphaned review with failures",
3762 body: "",
3763 author: "",
3764 url: "",
3765 labels: &[],
3766 origin: "github",
3767 })
3768 .await
3769 .unwrap();
3770 store
3771 .update_status(id, crate::store::TaskStatus::InReview)
3772 .await
3773 .unwrap();
3774 store
3775 .set_fields(id, &[("review_agent_failures", serde_json::json!(2))])
3776 .await
3777 .unwrap();
3778 set_in_review_entered_at_past(&store, id).await;
3779
3780 tick_recover_stuck_tasks(
3781 &backend,
3782 &tmux,
3783 "owner/repo",
3784 &task_manager,
3785 &EngineConfig::default(),
3786 &store,
3787 &std::collections::HashMap::new(),
3788 &Arc::new(DashMap::new()),
3789 )
3790 .await
3791 .unwrap();
3792
3793 let task = store.get(id).await.unwrap();
3794 assert_eq!(task.status, crate::store::TaskStatus::NeedsReview);
3795 assert_eq!(
3796 task.review_agent_failures, 0,
3797 "a dead review session is an infrastructure event, not an agent failure"
3798 );
3799 }
3800
3801 #[tokio::test]
3802 async fn recover_stuck_tasks_reclaims_hung_in_review_session() {
3803 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3804 let mock = MockBackend::new();
3805 let status_updates = std::sync::Arc::clone(&mock.status_updates);
3806 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3807 let task_manager = Arc::new(TaskManager::with_store(
3808 std::sync::Arc::clone(&backend),
3809 std::sync::Arc::clone(&store),
3810 "owner/repo".to_string(),
3811 ));
3812 let tmux = Arc::new(TmuxManager::new());
3813 let config = EngineConfig {
3814 no_session_stuck_timeout: 600,
3815 stuck_timeout: 1800,
3816 ..EngineConfig::default()
3817 };
3818
3819 let id = store
3820 .upsert_external(&crate::store::UpsertExternal {
3821 repo: "owner/repo",
3822 ext_id: "103",
3823 title: "Hung review session",
3824 body: "",
3825 author: "",
3826 url: "",
3827 labels: &[],
3828 origin: "github",
3829 })
3830 .await
3831 .unwrap();
3832 store
3833 .update_status(id, crate::store::TaskStatus::InReview)
3834 .await
3835 .unwrap();
3836 set_in_review_entered_at_past(&store, id).await;
3837 store
3838 .set_fields(id, &[("pr_number", serde_json::json!(43))])
3839 .await
3840 .unwrap();
3841
3842 let session_name = tmux.session_name("owner/repo", "103-review");
3843 let mut session_map = std::collections::HashMap::new();
3844 session_map.insert(session_name, true);
3845
3846 tick_recover_stuck_tasks(
3847 &backend,
3848 &tmux,
3849 "owner/repo",
3850 &task_manager,
3851 &config,
3852 &store,
3853 &session_map,
3854 &Arc::new(DashMap::new()),
3855 )
3856 .await
3857 .unwrap();
3858
3859 let updates = status_updates.lock().unwrap();
3860 assert_eq!(
3861 updates.len(),
3862 1,
3863 "a live hung review session must be reclaimed once the in_review entry ages past stuck_timeout"
3864 );
3865 assert_eq!(updates[0].0, "103");
3866 assert_eq!(updates[0].1, Status::NeedsReview);
3867 }
3868
3869 #[tokio::test]
3870 async fn untracked_repo_recovery_resets_stale_in_progress_to_new() {
3871 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3872 let tmux = Arc::new(TmuxManager::new());
3873 let config = EngineConfig {
3874 no_session_stuck_timeout: 600,
3875 stuck_timeout: 1800,
3876 ..EngineConfig::default()
3877 };
3878
3879 let id = store
3881 .upsert_external(&crate::store::UpsertExternal {
3882 repo: "owner/other",
3883 ext_id: "777",
3884 title: "Cross-repo stuck task",
3885 body: "",
3886 author: "",
3887 url: "",
3888 labels: &[],
3889 origin: "github",
3890 })
3891 .await
3892 .unwrap();
3893 store
3894 .update_status(id, crate::store::TaskStatus::InProgress)
3895 .await
3896 .unwrap();
3897 set_task_updated_at_past(&store, id).await;
3898
3899 let active = std::collections::HashSet::from(["owner/repo".to_string()]);
3900 recover_untracked_repo_stuck_tasks(
3901 &tmux,
3902 &config,
3903 &store,
3904 &std::collections::HashMap::new(),
3905 &Arc::new(DashMap::new()),
3906 &active,
3907 )
3908 .await;
3909
3910 let task = store.get(id).await.unwrap();
3911 assert_eq!(
3912 task.status,
3913 crate::store::TaskStatus::New,
3914 "cross-repo stale in_progress task should be reset to New"
3915 );
3916 assert_eq!(task.route_attempts, 0, "route attempts should be cleared");
3917 assert!(task.agent.is_none(), "agent should be cleared");
3918 assert!(task.model.is_none(), "model should be cleared");
3919 }
3920
3921 #[tokio::test]
3922 async fn untracked_repo_recovery_leaves_active_repos_to_their_own_pass() {
3923 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3924 let tmux = Arc::new(TmuxManager::new());
3925 let config = EngineConfig {
3926 no_session_stuck_timeout: 600,
3927 stuck_timeout: 1800,
3928 ..EngineConfig::default()
3929 };
3930 let id = store
3931 .upsert_external(&crate::store::UpsertExternal {
3932 repo: "owner/other",
3933 ext_id: "778",
3934 title: "Stuck task in an active repo",
3935 body: "",
3936 author: "",
3937 url: "",
3938 labels: &[],
3939 origin: "github",
3940 })
3941 .await
3942 .unwrap();
3943 store
3944 .update_status(id, crate::store::TaskStatus::InProgress)
3945 .await
3946 .unwrap();
3947 set_task_updated_at_past(&store, id).await;
3948
3949 let active =
3951 std::collections::HashSet::from(["owner/repo".to_string(), "owner/other".to_string()]);
3952 recover_untracked_repo_stuck_tasks(
3953 &tmux,
3954 &config,
3955 &store,
3956 &std::collections::HashMap::new(),
3957 &Arc::new(DashMap::new()),
3958 &active,
3959 )
3960 .await;
3961
3962 assert_eq!(
3963 store.get(id).await.unwrap().status,
3964 crate::store::TaskStatus::InProgress
3965 );
3966 }
3967
3968 #[tokio::test]
3969 async fn recover_stuck_tasks_finalizes_open_task_runs_rows() {
3970 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3971 let mock = MockBackend::new();
3972 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3973 let task_manager = Arc::new(TaskManager::with_store(
3974 std::sync::Arc::clone(&backend),
3975 std::sync::Arc::clone(&store),
3976 "owner/repo".to_string(),
3977 ));
3978 let tmux = Arc::new(TmuxManager::new());
3979 let config = EngineConfig {
3980 no_session_stuck_timeout: 600,
3981 stuck_timeout: 1800,
3982 ..EngineConfig::default()
3983 };
3984
3985 let id = store
3986 .create_internal("owner/repo", "Stuck with open run", "", "cron", "1", None)
3987 .await
3988 .unwrap();
3989 store
3990 .set_fields(
3991 id,
3992 &[
3993 ("agent", serde_json::json!("claude")),
3994 ("model", serde_json::json!("sonnet")),
3995 ("route_attempts", serde_json::json!(1)),
3996 ],
3997 )
3998 .await
3999 .unwrap();
4000 store
4001 .update_status(id, crate::store::TaskStatus::InProgress)
4002 .await
4003 .unwrap();
4004 set_task_updated_at_past(&store, id).await;
4005
4006 store
4008 .start_run(&crate::store::StartRun {
4009 task_id: id,
4010 attempt: 1,
4011 run_type: "agent",
4012 agent: "claude",
4013 model: "sonnet",
4014 command: "claude --model sonnet",
4015 prompt: "fix it",
4016 })
4017 .await
4018 .unwrap();
4019
4020 tick_recover_stuck_tasks(
4021 &backend,
4022 &tmux,
4023 "owner/repo",
4024 &task_manager,
4025 &config,
4026 &store,
4027 &std::collections::HashMap::new(),
4028 &Arc::new(DashMap::new()),
4029 )
4030 .await
4031 .unwrap();
4032
4033 let task = store.get(id).await.unwrap();
4034 assert_eq!(task.status, crate::store::TaskStatus::New);
4035
4036 let runs = store.get_runs(id).await.unwrap();
4037 assert_eq!(runs.len(), 1);
4038 assert_eq!(runs[0].outcome, "aborted");
4039 assert!(runs[0].completed_at.is_some());
4040 assert_eq!(
4041 runs[0].error,
4042 "stuck-task recovery: internal no session found"
4043 );
4044 }
4045
4046 #[tokio::test]
4063 async fn recover_stuck_tasks_skips_reclaim_while_dispatch_guard_held() {
4064 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4065 let mock = MockBackend::new();
4066 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4067 let task_manager = Arc::new(TaskManager::with_store(
4068 std::sync::Arc::clone(&backend),
4069 std::sync::Arc::clone(&store),
4070 "owner/repo".to_string(),
4071 ));
4072 let tmux = Arc::new(TmuxManager::new());
4073 let config = EngineConfig {
4074 no_session_stuck_timeout: 600,
4075 stuck_timeout: 1800,
4076 ..EngineConfig::default()
4077 };
4078
4079 let id = store
4080 .create_internal(
4081 "owner/repo",
4082 "Long-running daily job",
4083 "",
4084 "cron",
4085 "1",
4086 None,
4087 )
4088 .await
4089 .unwrap();
4090 store
4091 .set_fields(
4092 id,
4093 &[
4094 ("agent", serde_json::json!("claude")),
4095 ("model", serde_json::json!("sonnet")),
4096 ("route_attempts", serde_json::json!(1)),
4097 ],
4098 )
4099 .await
4100 .unwrap();
4101 store
4102 .update_status(id, crate::store::TaskStatus::InProgress)
4103 .await
4104 .unwrap();
4105 set_task_updated_at_past(&store, id).await;
4108
4109 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4112 dispatching.insert(
4113 format!("owner/repo/internal:{id}"),
4114 format!("internal:{id}"),
4115 );
4116
4117 tick_recover_stuck_tasks(
4118 &backend,
4119 &tmux,
4120 "owner/repo",
4121 &task_manager,
4122 &config,
4123 &store,
4124 &std::collections::HashMap::new(),
4125 &dispatching,
4126 )
4127 .await
4128 .unwrap();
4129
4130 let task = store.get(id).await.unwrap();
4131 assert_eq!(
4132 task.status,
4133 crate::store::TaskStatus::InProgress,
4134 "task should not be reclaimed while its dispatch guard is still held"
4135 );
4136
4137 let runs = store.get_runs(id).await.unwrap();
4139 assert!(runs.is_empty());
4140
4141 dispatching.clear();
4144 tick_recover_stuck_tasks(
4145 &backend,
4146 &tmux,
4147 "owner/repo",
4148 &task_manager,
4149 &config,
4150 &store,
4151 &std::collections::HashMap::new(),
4152 &dispatching,
4153 )
4154 .await
4155 .unwrap();
4156 let task = store.get(id).await.unwrap();
4157 assert_eq!(
4158 task.status,
4159 crate::store::TaskStatus::New,
4160 "task should still be reclaimed once the dispatch guard is released"
4161 );
4162 }
4163
4164 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
4180 async fn recover_stuck_tasks_never_reclaims_while_guard_held_under_real_concurrency() {
4181 const HAMMER_ITERATIONS: u32 = 300;
4182
4183 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4184 let mock = MockBackend::new();
4185 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4186 let task_manager = Arc::new(TaskManager::with_store(
4187 std::sync::Arc::clone(&backend),
4188 std::sync::Arc::clone(&store),
4189 "owner/repo".to_string(),
4190 ));
4191 let tmux = Arc::new(TmuxManager::new());
4192 let config = EngineConfig {
4193 no_session_stuck_timeout: 600,
4194 stuck_timeout: 1800,
4195 ..EngineConfig::default()
4196 };
4197
4198 let id = store
4199 .create_internal(
4200 "owner/repo",
4201 "Long-running daily job",
4202 "",
4203 "cron",
4204 "1",
4205 None,
4206 )
4207 .await
4208 .unwrap();
4209 store
4210 .set_fields(
4211 id,
4212 &[
4213 ("agent", serde_json::json!("claude")),
4214 ("model", serde_json::json!("sonnet")),
4215 ("route_attempts", serde_json::json!(1)),
4216 ],
4217 )
4218 .await
4219 .unwrap();
4220 store
4221 .update_status(id, crate::store::TaskStatus::InProgress)
4222 .await
4223 .unwrap();
4224 set_task_updated_at_past(&store, id).await;
4225
4226 let dispatch_key = format!("owner/repo/internal:{id}");
4227 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4228
4229 let claim_task_id = format!("internal:{id}");
4236 {
4237 use dashmap::mapref::entry::Entry;
4238 match dispatching.entry(dispatch_key.clone()) {
4239 Entry::Vacant(slot) => {
4240 slot.insert(claim_task_id);
4241 }
4242 Entry::Occupied(_) => panic!("dispatch key already claimed"),
4243 }
4244 }
4245 let guard = DispatchGuard::new(Arc::clone(&dispatching), dispatch_key.clone());
4246
4247 let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
4258 let holder = tokio::spawn(async move {
4259 let _ = release_rx.await;
4260 drop(guard);
4261 });
4262
4263 for i in 0..HAMMER_ITERATIONS {
4271 tick_recover_stuck_tasks(
4272 &backend,
4273 &tmux,
4274 "owner/repo",
4275 &task_manager,
4276 &config,
4277 &store,
4278 &std::collections::HashMap::new(),
4279 &dispatching,
4280 )
4281 .await
4282 .unwrap();
4283
4284 let task = store.get(id).await.unwrap();
4285 assert_eq!(
4286 task.status,
4287 crate::store::TaskStatus::InProgress,
4288 "task must never be reclaimed while the dispatch guard is deterministically \
4289 still held, even under real cross-thread concurrency (dispatch_key={dispatch_key}, \
4290 iteration={i}, contains_key_now={}, map_len_now={})",
4291 dispatching.contains_key(&dispatch_key),
4292 dispatching.len(),
4293 );
4294 tokio::task::yield_now().await;
4295 }
4296
4297 let _ = release_tx.send(());
4299 holder.await.unwrap();
4300
4301 tick_recover_stuck_tasks(
4303 &backend,
4304 &tmux,
4305 "owner/repo",
4306 &task_manager,
4307 &config,
4308 &store,
4309 &std::collections::HashMap::new(),
4310 &dispatching,
4311 )
4312 .await
4313 .unwrap();
4314 let task = store.get(id).await.unwrap();
4315 assert_eq!(
4316 task.status,
4317 crate::store::TaskStatus::New,
4318 "task should be reclaimed once the dispatch guard is released"
4319 );
4320 }
4321
4322 #[tokio::test]
4323 async fn recover_stuck_tasks_skips_external_in_review_waiting_for_human_review() {
4324 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4325 let mock = MockBackend::new();
4326 let status_updates = std::sync::Arc::clone(&mock.status_updates);
4327 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4328 let task_manager = Arc::new(TaskManager::with_store(
4329 std::sync::Arc::clone(&backend),
4330 std::sync::Arc::clone(&store),
4331 "owner/repo".to_string(),
4332 ));
4333 let tmux = Arc::new(TmuxManager::new());
4334 let config = EngineConfig {
4335 no_session_stuck_timeout: 600,
4336 stuck_timeout: 1800,
4337 ..EngineConfig::default()
4338 };
4339
4340 let id = store
4341 .upsert_external(&crate::store::UpsertExternal {
4342 repo: "owner/repo",
4343 ext_id: "101",
4344 title: "Waiting For Human Review",
4345 body: "",
4346 author: "",
4347 url: "",
4348 labels: &[],
4349 origin: "github",
4350 })
4351 .await
4352 .unwrap();
4353 store
4354 .update_status(id, crate::store::TaskStatus::InReview)
4355 .await
4356 .unwrap();
4357 set_task_updated_at_past(&store, id).await;
4358
4359 tick_recover_stuck_tasks(
4360 &backend,
4361 &tmux,
4362 "owner/repo",
4363 &task_manager,
4364 &config,
4365 &store,
4366 &std::collections::HashMap::new(),
4367 &Arc::new(DashMap::new()),
4368 )
4369 .await
4370 .unwrap();
4371
4372 let updates = status_updates.lock().unwrap();
4373 assert!(
4374 updates.is_empty(),
4375 "InReview task waiting for human review should not be reset"
4376 );
4377 }
4378
4379 #[tokio::test]
4380 async fn unblock_parents_no_blocked_tasks() {
4381 let mock = MockBackend::new(); let status_updates = std::sync::Arc::clone(&mock.status_updates);
4383 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4384 let task_manager = make_task_manager(std::sync::Arc::clone(&backend));
4385 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4386
4387 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
4388 .await
4389 .unwrap();
4390
4391 assert!(status_updates.lock().unwrap().is_empty());
4392 }
4393
4394 #[tokio::test]
4395 async fn unblock_parents_handles_multiple_blocked_tasks() {
4396 let mut mock = MockBackend::new();
4397
4398 let t40 = make_task("40", &["status:blocked"]);
4400 mock.blocked_tasks.push(t40);
4401 mock.sub_issues
4402 .insert("40".to_string(), vec![ExternalId("41".to_string())]);
4403 mock.tasks_by_id
4404 .insert("41".to_string(), make_task("41", &["status:done"]));
4405
4406 let t50 = make_task("50", &["status:blocked"]);
4408 mock.blocked_tasks.push(t50);
4409 mock.sub_issues
4410 .insert("50".to_string(), vec![ExternalId("51".to_string())]);
4411 mock.tasks_by_id
4412 .insert("51".to_string(), make_task("51", &["status:in_progress"]));
4413
4414 let status_updates = std::sync::Arc::clone(&mock.status_updates);
4415 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4416 let task_manager = make_task_manager(std::sync::Arc::clone(&backend));
4417 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4418
4419 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
4420 .await
4421 .unwrap();
4422
4423 let updates = status_updates.lock().unwrap();
4424 assert_eq!(updates.len(), 1, "only one parent should be unblocked");
4425 assert_eq!(updates[0].0, "40", "task 40 should be unblocked");
4426 assert_eq!(updates[0].1, Status::New);
4427 }
4428
4429 #[tokio::test]
4432 async fn tick_all_is_a_no_op_without_projects() {
4433 let transport = Arc::new(Transport::new());
4434 let capture = Arc::new(CaptureService::new(std::sync::Arc::clone(&transport)));
4435 let tmux = Arc::new(TmuxManager::new());
4436 let semaphore = Arc::new(Semaphore::new(1));
4437 let router = Arc::new(tokio::sync::RwLock::new(cooled_review_router_for_test()));
4438 let (weight_tx, mut weight_rx) = mpsc::channel(8);
4439 let dispatching = Arc::new(DashMap::new());
4440 let auto_merge_in_flight = Arc::new(DashSet::new());
4441
4442 tick_all(
4443 &[],
4444 &tmux,
4445 &capture,
4446 &semaphore,
4447 &EngineConfig::default(),
4448 &router,
4449 &weight_tx,
4450 &dispatching,
4451 Some(&transport),
4452 &std::collections::HashSet::new(),
4453 &auto_merge_in_flight,
4454 )
4455 .await;
4456
4457 assert!(dispatching.is_empty(), "no project, so no dispatch claimed");
4458 assert!(
4459 weight_rx.try_recv().is_err(),
4460 "no project, so no weight signal"
4461 );
4462 }
4463
4464 #[serial_test::serial(cooldown_state)]
4469 #[tokio::test]
4470 async fn tick_all_runs_the_global_needs_review_sweep_once_for_two_projects() {
4471 use crate::engine::router::{Router, RouterConfig};
4472 use crate::store::UpsertExternal;
4473
4474 crate::engine::cooldown::reset_global_state().await;
4475
4476 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4477 let id = store
4478 .upsert_external(&UpsertExternal {
4479 repo: "owner/one",
4480 ext_id: "7",
4481 title: "Stale needs_review task",
4482 body: "",
4483 author: "user",
4484 url: "https://github.com/owner/one/issues/7",
4485 labels: &[],
4486 origin: "github",
4487 })
4488 .await
4489 .unwrap();
4490 store
4491 .update_status(id, crate::store::TaskStatus::NeedsReview)
4492 .await
4493 .unwrap();
4494 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4495 .bind(id)
4496 .execute(store.pool())
4497 .await
4498 .unwrap();
4499
4500 let engines: Vec<ProjectEngine> = ["owner/one", "owner/two"]
4501 .iter()
4502 .map(|repo| {
4503 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4504 ProjectEngine {
4505 repo: repo.to_string(),
4506 project_dir: std::path::PathBuf::from("/nonexistent"),
4507 backend: std::sync::Arc::clone(&backend),
4508 task_manager: Arc::new(TaskManager::with_store(
4509 backend,
4510 std::sync::Arc::clone(&store),
4511 repo.to_string(),
4512 )),
4513 runner: Arc::new(crate::engine::runner::TaskRunner::new(repo.to_string())),
4514 store: std::sync::Arc::clone(&store),
4515 }
4516 })
4517 .collect();
4518
4519 let mut config = RouterConfig::default();
4521 config
4522 .model_map
4523 .entry("review".to_string())
4524 .or_default()
4525 .insert("claude".to_string(), vec!["sonnet".to_string()]);
4526 let router = Arc::new(tokio::sync::RwLock::new(Router::new_for_test(
4527 config,
4528 vec!["claude".to_string()],
4529 )));
4530
4531 let transport = Arc::new(Transport::new());
4532 let capture = Arc::new(CaptureService::new(std::sync::Arc::clone(&transport)));
4533 let (weight_tx, _weight_rx) = mpsc::channel(8);
4534
4535 tick_all(
4536 &engines,
4537 &Arc::new(TmuxManager::new()),
4538 &capture,
4539 &Arc::new(Semaphore::new(1)),
4540 &EngineConfig::default(),
4541 &router,
4542 &weight_tx,
4543 &Arc::new(DashMap::new()),
4544 Some(&transport),
4545 &engines.iter().map(|e| e.repo.clone()).collect(),
4546 &Arc::new(DashSet::new()),
4547 )
4548 .await;
4549
4550 assert_eq!(
4551 store.get(id).await.unwrap().needs_review_refires,
4552 1,
4553 "two projects must share one pass of the global NeedsReview sweep"
4554 );
4555 }
4556
4557 #[tokio::test]
4560 async fn tick_all_does_not_kill_sessions_it_did_not_create() {
4561 let tmux = Arc::new(TmuxManager::new());
4562 let Ok(session_name) = tmux
4563 .create_session("other/foreign", "3889", "/tmp", "sleep 30", &[])
4564 .await
4565 else {
4566 eprintln!("Skipping test: tmux not available or failed to create test session");
4567 return;
4568 };
4569 let _guard = SessionGuard(session_name.clone());
4570
4571 tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
4574
4575 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4576 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4577 let engines = vec![ProjectEngine {
4578 repo: "owner/one".to_string(),
4579 project_dir: std::path::PathBuf::from("/nonexistent"),
4580 backend: std::sync::Arc::clone(&backend),
4581 task_manager: Arc::new(TaskManager::with_store(
4582 backend,
4583 std::sync::Arc::clone(&store),
4584 "owner/one".to_string(),
4585 )),
4586 runner: Arc::new(crate::engine::runner::TaskRunner::new(
4587 "owner/one".to_string(),
4588 )),
4589 store,
4590 }];
4591
4592 let transport = Arc::new(Transport::new());
4593 let capture = Arc::new(CaptureService::new(std::sync::Arc::clone(&transport)));
4594 let (weight_tx, _weight_rx) = mpsc::channel(8);
4595 let router = Arc::new(tokio::sync::RwLock::new(cooled_review_router_for_test()));
4596
4597 tick_all(
4598 &engines,
4599 &tmux,
4600 &capture,
4601 &Arc::new(Semaphore::new(1)),
4602 &EngineConfig::default(),
4603 &router,
4604 &weight_tx,
4605 &Arc::new(DashMap::new()),
4606 Some(&transport),
4607 &engines.iter().map(|e| e.repo.clone()).collect(),
4608 &Arc::new(DashSet::new()),
4609 )
4610 .await;
4611
4612 assert!(
4613 tmux.session_exists(&session_name).await,
4614 "tick_all must not kill a tmux session it did not start"
4615 );
4616 }
4617
4618 #[tokio::test]
4619 async fn detect_silent_agents_skips_backend_for_internal_tasks() {
4620 let transport = Arc::new(Transport::new());
4621 let capture = Arc::new(CaptureService::new(transport));
4622 let mock = Arc::new(MockBackend::new());
4623 let backend: Arc<dyn ExternalBackend> = Arc::<MockBackend>::clone(&mock);
4624 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4625 let task_manager = Arc::new(TaskManager::with_store(
4626 std::sync::Arc::clone(&backend),
4627 std::sync::Arc::clone(&store),
4628 "owner/repo".to_string(),
4629 ));
4630 let tmux = Arc::new(TmuxManager::new());
4631 let config = EngineConfig {
4632 silence_grace_period: 0,
4633 ..EngineConfig::default()
4634 };
4635
4636 let internal_id = store
4637 .create_internal("owner/repo", "Silent internal", "", "cron", "1", None)
4638 .await
4639 .unwrap();
4640 store
4641 .update_status(internal_id, crate::store::TaskStatus::InProgress)
4642 .await
4643 .unwrap();
4644
4645 let task_id = format!("internal:{internal_id}");
4646 capture
4647 .register_session("owner/repo", &task_id, "orch-test-internal")
4648 .await;
4649 tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
4650
4651 tick_detect_silent_agents(
4652 &tmux,
4653 "owner/repo",
4654 &capture,
4655 &backend,
4656 &task_manager,
4657 &config,
4658 &store,
4659 )
4660 .await
4661 .unwrap();
4662
4663 assert!(
4664 mock.removed_labels.lock().unwrap().is_empty(),
4665 "internal tasks should skip backend label removal"
4666 );
4667 assert!(
4668 mock.posted_comments.lock().unwrap().is_empty(),
4669 "internal tasks should skip backend comments"
4670 );
4671
4672 let task = store.get(internal_id).await.unwrap();
4673 assert!(
4677 matches!(
4678 task.status,
4679 crate::store::TaskStatus::Routed | crate::store::TaskStatus::New
4680 ),
4681 "internal task should be routed to fallback or reset to new, got {:?}",
4682 task.status
4683 );
4684 }
4685
4686 #[tokio::test]
4687 async fn detect_silent_agents_finalizes_open_task_runs_rows() {
4688 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4689 let mock = MockBackend::new();
4690 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4691 let task_manager = Arc::new(TaskManager::with_store(
4692 std::sync::Arc::clone(&backend),
4693 std::sync::Arc::clone(&store),
4694 "owner/repo".to_string(),
4695 ));
4696 let transport = Arc::new(Transport::new());
4697 let capture = Arc::new(CaptureService::new(transport));
4698 let tmux = Arc::new(TmuxManager::new());
4699 let config = EngineConfig {
4700 silence_grace_period: 60,
4701 silence_cooldown: 300,
4702 ..EngineConfig::default()
4703 };
4704
4705 let internal_id = store
4706 .create_internal("owner/repo", "Silent with open run", "", "cron", "1", None)
4707 .await
4708 .unwrap();
4709 store
4710 .set_fields(
4711 internal_id,
4712 &[
4713 ("agent", serde_json::json!("claude")),
4714 ("model", serde_json::json!("sonnet")),
4715 ],
4716 )
4717 .await
4718 .unwrap();
4719 store
4720 .update_status(internal_id, crate::store::TaskStatus::InProgress)
4721 .await
4722 .unwrap();
4723
4724 let task_id = format!("internal:{internal_id}");
4725 capture
4726 .register_session("owner/repo", &task_id, "orch-test-silent-run")
4727 .await;
4728 capture
4729 .set_buffer_state_for_test(
4730 "owner/repo",
4731 &task_id,
4732 false,
4733 false,
4734 chrono::Utc::now() - chrono::Duration::seconds(120),
4735 )
4736 .await;
4737
4738 store
4740 .start_run(&crate::store::StartRun {
4741 task_id: internal_id,
4742 attempt: 1,
4743 run_type: "agent",
4744 agent: "claude",
4745 model: "sonnet",
4746 command: "claude --model sonnet",
4747 prompt: "fix it",
4748 })
4749 .await
4750 .unwrap();
4751
4752 tick_detect_silent_agents(
4753 &tmux,
4754 "owner/repo",
4755 &capture,
4756 &backend,
4757 &task_manager,
4758 &config,
4759 &store,
4760 )
4761 .await
4762 .unwrap();
4763
4764 let runs = store.get_runs(internal_id).await.unwrap();
4765 assert_eq!(runs.len(), 1);
4766 assert_eq!(runs[0].outcome, "aborted");
4767 assert!(runs[0].completed_at.is_some());
4768 assert_eq!(runs[0].error, "silence detection: session killed");
4769 }
4770
4771 #[tokio::test]
4782 async fn phase1_session_completion_touch_prevents_phase2_reclaim() {
4783 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4784 let mock = MockBackend::new();
4785 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4786 let task_manager = Arc::new(TaskManager::with_store(
4787 std::sync::Arc::clone(&backend),
4788 std::sync::Arc::clone(&store),
4789 "owner/repo".to_string(),
4790 ));
4791 let tmux = Arc::new(TmuxManager::new());
4792 let config = EngineConfig {
4793 no_session_stuck_timeout: 600,
4794 stuck_timeout: 1800,
4795 ..EngineConfig::default()
4796 };
4797
4798 let id = store
4802 .create_internal("owner/repo", "Completed task", "", "cron", "1", None)
4803 .await
4804 .unwrap();
4805 store
4806 .update_status(id, crate::store::TaskStatus::InProgress)
4807 .await
4808 .unwrap();
4809 set_task_updated_at_past(&store, id).await;
4810
4811 let task_id = format!("internal:{id}");
4814 store_touch_updated_at(&Some(Arc::clone(&store)), "owner/repo", &task_id).await;
4815
4816 tick_recover_stuck_tasks(
4818 &backend,
4819 &tmux,
4820 "owner/repo",
4821 &task_manager,
4822 &config,
4823 &store,
4824 &std::collections::HashMap::new(),
4825 &Arc::new(DashMap::new()),
4826 )
4827 .await
4828 .unwrap();
4829
4830 let task = store.get(id).await.unwrap();
4831 assert_eq!(
4832 task.status,
4833 crate::store::TaskStatus::InProgress,
4834 "Phase 2 must not reclaim a task whose updated_at was just touched by Phase 1"
4835 );
4836 }
4837
4838 #[tokio::test]
4839 async fn stuck_task_timing_treats_dead_session_as_missing() {
4840 let tmux = Arc::new(TmuxManager::new());
4841 let repo = "owner/repo";
4842 let task_id = "internal:33498";
4843 let session_name = tmux.session_name(repo, task_id);
4844
4845 let create_result = tmux
4846 .command()
4847 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
4848 .output()
4849 .await;
4850
4851 match create_result {
4852 Ok(o) if o.status.success() => {}
4853 _ => {
4854 eprintln!("Skipping test: tmux not available or failed to create test session");
4855 return;
4856 }
4857 }
4858
4859 let _guard = SessionGuard(session_name.clone());
4860
4861 let set_option_result = tmux
4862 .command()
4863 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
4864 .output()
4865 .await;
4866 if !matches!(set_option_result, Ok(ref o) if o.status.success()) {
4867 eprintln!("Skipping test: unable to set tmux remain-on-exit option");
4868 return;
4869 }
4870
4871 let send_exit_result = tmux
4872 .command()
4873 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
4874 .output()
4875 .await;
4876 if !matches!(send_exit_result, Ok(ref o) if o.status.success()) {
4877 eprintln!("Skipping test: unable to exit tmux pane");
4878 return;
4879 }
4880
4881 for _ in 0..20 {
4883 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4884 if !tmux.session_is_running(&session_name).await {
4885 break;
4886 }
4887 }
4888
4889 let mut session_map = tmux.batch_session_active().await;
4894 for _ in 0..20 {
4895 if !session_map.get(&session_name).copied().unwrap_or(false) {
4896 break;
4897 }
4898 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4899 session_map = tmux.batch_session_active().await;
4900 }
4901 if session_map.get(&session_name).copied().unwrap_or(false) {
4902 eprintln!(
4903 "Skipping test: session still alive in batch_session_active after retries (CI tmux lag)"
4904 );
4905 return;
4906 }
4907
4908 let config = EngineConfig {
4909 no_session_stuck_timeout: 600,
4910 stuck_timeout: 1800,
4911 ..EngineConfig::default()
4912 };
4913 let updated_at = (chrono::Utc::now()
4914 - chrono::Duration::seconds(
4915 i64::try_from(config.no_session_stuck_timeout).expect("timeout fits in i64") + 5,
4916 ))
4917 .to_rfc3339();
4918
4919 let timing = stuck_task_timing_from_map(
4920 &tmux,
4921 repo,
4922 task_id,
4923 &updated_at,
4924 &config,
4925 "parse failure",
4926 &session_map,
4927 )
4928 .expect("dead session should be treated as missing");
4929
4930 assert!(!timing.has_session);
4931 assert_eq!(timing.threshold, config.no_session_stuck_timeout);
4932 }
4933
4934 #[tokio::test]
4935 #[serial_test::serial(suspend_state)]
4936 async fn stuck_task_timing_discounts_host_suspend_gap() {
4937 super::super::suspend::clear_for_test();
4938
4939 let tmux = Arc::new(TmuxManager::new());
4940 let repo = "owner/repo";
4941 let task_id = "internal:99001";
4942 let session_map = std::collections::HashMap::new();
4943
4944 let config = EngineConfig {
4945 no_session_stuck_timeout: 1800,
4946 stuck_timeout: 1800,
4947 ..EngineConfig::default()
4948 };
4949
4950 let updated_at = (chrono::Utc::now() - chrono::Duration::minutes(33)).to_rfc3339();
4954 let suspend_gap_detected_at = chrono::Utc::now() - chrono::Duration::minutes(1);
4955 super::super::suspend::inject_gap_for_test(
4956 suspend_gap_detected_at,
4957 chrono::Duration::minutes(30),
4958 );
4959
4960 let timing = stuck_task_timing_from_map(
4961 &tmux,
4962 repo,
4963 task_id,
4964 &updated_at,
4965 &config,
4966 "parse failure",
4967 &session_map,
4968 );
4969
4970 assert!(
4971 timing.is_none(),
4972 "task age should be discounted below the stuck threshold once suspend time is subtracted, got {timing:?}"
4973 );
4974
4975 super::super::suspend::clear_for_test();
4976 }
4977
4978 #[tokio::test]
4979 async fn recover_stuck_tasks_reclaims_routed_task_blocked_by_stale_session() {
4980 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4981 let mock = MockBackend::new();
4982 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4983 let task_manager = Arc::new(TaskManager::with_store(
4984 std::sync::Arc::clone(&backend),
4985 std::sync::Arc::clone(&store),
4986 "owner/repo".to_string(),
4987 ));
4988 let tmux = Arc::new(TmuxManager::new());
4989 let config = EngineConfig {
4990 no_session_stuck_timeout: 600,
4991 stuck_timeout: 1,
4992 ..EngineConfig::default()
4993 };
4994
4995 let id = store
4996 .create_internal("owner/repo", "Blocked routed task", "", "cron", "1", None)
4997 .await
4998 .unwrap();
4999 store
5000 .update_status(id, crate::store::TaskStatus::Routed)
5001 .await
5002 .unwrap();
5003 set_task_updated_at_past(&store, id).await;
5004
5005 let task_id = format!("internal:{id}");
5006 let session_name = tmux.session_name("owner/repo", &task_id);
5007 let create_result = tmux
5008 .command()
5009 .args([
5010 "new-session",
5011 "-d",
5012 "-s",
5013 &session_name,
5014 "-c",
5015 "/tmp",
5016 "sleep 60",
5017 ])
5018 .output()
5019 .await;
5020
5021 match create_result {
5022 Ok(o) if o.status.success() => {}
5023 _ => {
5024 eprintln!("Skipping test: tmux not available or failed to create test session");
5025 return;
5026 }
5027 }
5028
5029 let session_map = tmux.batch_session_active().await;
5030 assert_eq!(session_map.get(&session_name).copied(), Some(true));
5031
5032 tick_recover_stuck_tasks(
5033 &backend,
5034 &tmux,
5035 "owner/repo",
5036 &task_manager,
5037 &config,
5038 &store,
5039 &session_map,
5040 &Arc::new(DashMap::new()),
5041 )
5042 .await
5043 .unwrap();
5044
5045 let task = store.get(id).await.unwrap();
5046 assert_eq!(
5047 task.status,
5048 crate::store::TaskStatus::New,
5049 "routed task blocked by a stale session should be reset for re-routing"
5050 );
5051 assert!(
5052 !tmux.session_exists(&session_name).await,
5053 "recovery should kill the stale tmux session so dispatch can proceed"
5054 );
5055 }
5056
5057 #[tokio::test]
5071 async fn dispatch_does_not_deadlock_under_write_lock() {
5072 use crate::engine::router::Router;
5073 use tokio::sync::RwLock;
5074
5075 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5076 let mock = MockBackend::new();
5077 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5078 let task_manager = Arc::new(TaskManager::with_store(
5079 std::sync::Arc::clone(&backend),
5080 std::sync::Arc::clone(&store),
5081 "owner/repo".to_string(),
5082 ));
5083 let tmux = Arc::new(TmuxManager::new());
5084 let semaphore = Arc::new(Semaphore::new(4));
5085 let (weight_tx, _weight_rx) = mpsc::channel(16);
5086 let dispatching = Arc::new(DashMap::new());
5087 let transport = Arc::new(Transport::new());
5088 let capture = Arc::new(CaptureService::new(transport));
5089
5090 let router_config = crate::engine::router::RouterConfig::default();
5091 let router = Router::new_for_test(router_config, vec!["claude".to_string()]);
5092 let router_arc = Arc::new(RwLock::new(router));
5093
5094 let write_guard = router_arc.write().await;
5098 let dispatch_mode = dispatch_mode_from_router(&write_guard);
5099
5100 let result = tokio::time::timeout(
5101 std::time::Duration::from_secs(2),
5102 tick_dispatch_tasks(
5103 &backend,
5104 &tmux,
5105 "owner/repo",
5106 &Arc::new(crate::engine::runner::TaskRunner::new(
5107 "owner/repo".to_string(),
5108 )),
5109 &capture,
5110 &semaphore,
5111 &task_manager,
5112 &weight_tx,
5113 dispatch_mode,
5114 &dispatching,
5115 &store,
5116 &std::collections::HashMap::new(),
5117 ),
5118 )
5119 .await;
5120
5121 assert!(
5122 result.is_ok(),
5123 "tick_dispatch_tasks deadlocked! It tried to acquire a read lock \
5124 on router_arc while a write lock was already held (issue #1361)"
5125 );
5126 }
5127
5128 #[tokio::test]
5136 async fn dispatch_skips_task_whose_routed_model_entered_cooldown() {
5137 use crate::store::{StoreRoute, TaskStatus as DbStatus, UpsertExternal};
5138
5139 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5140 let mock = MockBackend::new();
5141 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5142 let task_manager = Arc::new(TaskManager::with_store(
5143 std::sync::Arc::clone(&backend),
5144 std::sync::Arc::clone(&store),
5145 "owner/repo".to_string(),
5146 ));
5147 let tmux = Arc::new(TmuxManager::new());
5148 let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
5149 let semaphore = Arc::new(Semaphore::new(4));
5150 let (weight_tx, _weight_rx) = mpsc::channel(16);
5151 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5152 let transport = Arc::new(Transport::new());
5153 let capture = Arc::new(CaptureService::new(transport));
5154
5155 let id = store
5156 .upsert_external(&UpsertExternal {
5157 repo: "owner/repo",
5158 ext_id: "999",
5159 title: "Stale routed task",
5160 body: "",
5161 author: "user",
5162 url: "https://github.com/owner/repo/issues/999",
5163 labels: &[],
5164 origin: "github",
5165 })
5166 .await
5167 .unwrap();
5168 store
5169 .store_route(&StoreRoute {
5170 id,
5171 agent: "minimax",
5172 model: Some("opus"),
5173 complexity: "medium",
5174 estimate: 0,
5175 reason: "test",
5176 profile: "{}",
5177 skills: "[]",
5178 })
5179 .await
5180 .unwrap();
5181 store.update_status(id, DbStatus::Routed).await.unwrap();
5182
5183 crate::engine::cooldown::set_model_cooldown(
5186 "minimax",
5187 "opus",
5188 300,
5189 "rate_limit_usage_window",
5190 )
5191 .await;
5192
5193 let dispatch_mode = DispatchMode {
5194 is_degraded: false,
5195 healthy_agents: 1,
5196 threshold: 1,
5197 };
5198
5199 tick_dispatch_tasks(
5200 &backend,
5201 &tmux,
5202 "owner/repo",
5203 &runner,
5204 &capture,
5205 &semaphore,
5206 &task_manager,
5207 &weight_tx,
5208 dispatch_mode,
5209 &dispatching,
5210 &store,
5211 &std::collections::HashMap::new(),
5212 )
5213 .await
5214 .unwrap();
5215
5216 let task = store.get(id).await.unwrap();
5217 assert_eq!(
5218 task.status,
5219 DbStatus::Routed,
5220 "task whose routed model is now cooled down must stay Routed, not be dispatched"
5221 );
5222 assert!(
5223 dispatching.is_empty(),
5224 "dispatch guard must be released when skipping a stale-cooldown task"
5225 );
5226 }
5227
5228 #[tokio::test]
5229 async fn dispatch_skips_a_task_with_an_open_dependency() {
5230 use crate::store::{
5231 DepSource, NewDependency, StoreRoute, TaskStatus as DbStatus, UpsertExternal,
5232 };
5233
5234 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5235 let mock = MockBackend::new();
5236 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5237 let task_manager = Arc::new(TaskManager::with_store(
5238 std::sync::Arc::clone(&backend),
5239 std::sync::Arc::clone(&store),
5240 "owner/repo".to_string(),
5241 ));
5242 let tmux = Arc::new(TmuxManager::new());
5243 let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
5244 let semaphore = Arc::new(Semaphore::new(4));
5245 let (weight_tx, _weight_rx) = mpsc::channel(16);
5246 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5247 let transport = Arc::new(Transport::new());
5248 let capture = Arc::new(CaptureService::new(transport));
5249
5250 let id = store
5251 .upsert_external(&UpsertExternal {
5252 repo: "owner/repo",
5253 ext_id: "42",
5254 title: "Waiting routed task",
5255 body: "",
5256 author: "user",
5257 url: "https://github.com/owner/repo/issues/42",
5258 labels: &[],
5259 origin: "github",
5260 })
5261 .await
5262 .unwrap();
5263 store
5264 .replace_dependencies(
5265 id,
5266 &[NewDependency {
5267 repo: "owner/repo".into(),
5268 number: 7,
5269 source: DepSource::Github,
5270 }],
5271 )
5272 .await
5273 .unwrap();
5274 store
5275 .store_route(&StoreRoute {
5276 id,
5277 agent: "claude",
5278 model: Some("haiku"),
5279 complexity: "simple",
5280 estimate: 0,
5281 reason: "test",
5282 profile: "{}",
5283 skills: "[]",
5284 })
5285 .await
5286 .unwrap();
5287 store.update_status(id, DbStatus::Routed).await.unwrap();
5288
5289 let dispatch_mode = DispatchMode {
5290 is_degraded: false,
5291 healthy_agents: 1,
5292 threshold: 1,
5293 };
5294 tick_dispatch_tasks(
5295 &backend,
5296 &tmux,
5297 "owner/repo",
5298 &runner,
5299 &capture,
5300 &semaphore,
5301 &task_manager,
5302 &weight_tx,
5303 dispatch_mode,
5304 &dispatching,
5305 &store,
5306 &std::collections::HashMap::new(),
5307 )
5308 .await
5309 .unwrap();
5310
5311 let task = store.get(id).await.unwrap();
5312 assert_eq!(task.status, DbStatus::Waiting);
5313 assert!(dispatching.is_empty());
5314 }
5315
5316 #[tokio::test]
5329 async fn completed_session_does_not_cause_stuck_reclaim_race() {
5330 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5331 let mock = MockBackend::new();
5332 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5333 let task_manager = Arc::new(TaskManager::with_store(
5334 std::sync::Arc::clone(&backend),
5335 std::sync::Arc::clone(&store),
5336 "owner/repo".to_string(),
5337 ));
5338 let tmux = Arc::new(TmuxManager::new());
5339 let transport = Arc::new(Transport::new());
5340 let capture = Arc::new(CaptureService::new(transport));
5341
5342 let id = store
5344 .upsert_external(&crate::store::UpsertExternal {
5345 repo: "owner/repo",
5346 ext_id: "200",
5347 title: "Race Task",
5348 body: "",
5349 author: "",
5350 url: "",
5351 labels: &[],
5352 origin: "github",
5353 })
5354 .await
5355 .unwrap();
5356 store
5357 .update_status(id, crate::store::TaskStatus::InProgress)
5358 .await
5359 .unwrap();
5360
5361 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
5363 .bind(id)
5364 .execute(store.pool())
5365 .await
5366 .unwrap();
5367
5368 let task_id = "200";
5370 let session_name = tmux.session_name("owner/repo", task_id);
5371 let create_result = tmux
5372 .command()
5373 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
5374 .output()
5375 .await;
5376 match create_result {
5377 Ok(o) if o.status.success() => {}
5378 _ => {
5379 eprintln!("Skipping test: tmux not available or failed to create test session");
5380 return;
5381 }
5382 }
5383 let _guard = SessionGuard(session_name.clone());
5384
5385 let _ = tmux
5387 .command()
5388 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
5389 .output()
5390 .await;
5391 let _ = tmux
5392 .command()
5393 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
5394 .output()
5395 .await;
5396
5397 for _ in 0..20 {
5399 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5400 if !tmux.session_is_running(&session_name).await {
5401 break;
5402 }
5403 }
5404
5405 let task_key = task_id.to_string();
5407 capture
5408 .register_session("owner/repo", &task_key, &session_name)
5409 .await;
5410
5411 let config = EngineConfig {
5413 no_session_stuck_timeout: 600,
5414 stuck_timeout: 1800,
5415 ..EngineConfig::default()
5416 };
5417
5418 tick_check_session_completions(&tmux, "owner/repo", &capture, &store)
5419 .await
5420 .unwrap();
5421
5422 {
5428 let task_state = store.get(id).await.unwrap();
5429 if task_state.updated_at.starts_with("2020") {
5430 eprintln!(
5431 "Skipping test: tick_check_session_completions did not update stored \
5432 updated_at — session was not in snapshot (CI tmux environment issue)"
5433 );
5434 return;
5435 }
5436 }
5437
5438 tick_recover_stuck_tasks(
5440 &backend,
5441 &tmux,
5442 "owner/repo",
5443 &task_manager,
5444 &config,
5445 &store,
5446 &std::collections::HashMap::new(),
5447 &Arc::new(DashMap::new()),
5448 )
5449 .await
5450 .unwrap();
5451
5452 let task = store.get(id).await.unwrap();
5454 assert_eq!(
5455 task.status,
5456 crate::store::TaskStatus::InProgress,
5457 "task should not be reclaimed to New"
5458 );
5459 }
5460
5461 #[tokio::test]
5471 async fn session_completion_matches_repo_configured_with_git_suffix() {
5472 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5473 let tmux = Arc::new(TmuxManager::new());
5474 let transport = Arc::new(Transport::new());
5475 let capture = Arc::new(CaptureService::new(transport));
5476
5477 let repo = "owner/repo.git";
5478 let id = store
5479 .upsert_external(&crate::store::UpsertExternal {
5480 repo,
5481 ext_id: "201",
5482 title: "Git-suffix repo task",
5483 body: "",
5484 author: "",
5485 url: "",
5486 labels: &[],
5487 origin: "github",
5488 })
5489 .await
5490 .unwrap();
5491 store
5492 .update_status(id, crate::store::TaskStatus::InProgress)
5493 .await
5494 .unwrap();
5495 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
5496 .bind(id)
5497 .execute(store.pool())
5498 .await
5499 .unwrap();
5500
5501 let task_id = "201";
5504 let session_name = tmux.session_name(repo, task_id);
5505 let create_result = tmux
5506 .command()
5507 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
5508 .output()
5509 .await;
5510 match create_result {
5511 Ok(o) if o.status.success() => {}
5512 _ => {
5513 eprintln!("Skipping test: tmux not available or failed to create test session");
5514 return;
5515 }
5516 }
5517 let _guard = SessionGuard(session_name.clone());
5518
5519 let _ = tmux
5520 .command()
5521 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
5522 .output()
5523 .await;
5524 let _ = tmux
5525 .command()
5526 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
5527 .output()
5528 .await;
5529
5530 for _ in 0..20 {
5531 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5532 if !tmux.session_is_running(&session_name).await {
5533 break;
5534 }
5535 }
5536
5537 capture.register_session(repo, task_id, &session_name).await;
5538
5539 tick_check_session_completions(&tmux, repo, &capture, &store)
5540 .await
5541 .unwrap();
5542
5543 let task_state = store.get(id).await.unwrap();
5544 if task_state.updated_at.starts_with("2020") {
5545 eprintln!(
5546 "Skipping test: tick_check_session_completions did not update stored \
5547 updated_at — session was not in snapshot (CI tmux environment issue)"
5548 );
5549 return;
5550 }
5551 assert!(
5552 !task_state.updated_at.starts_with("2020"),
5553 "tick_check_session_completions must match its own repo's live sessions \
5554 even when the repo is configured with a \".git\" suffix"
5555 );
5556 }
5557
5558 #[tokio::test]
5564 async fn job_scheduler_returns_ok_on_duplicate_job_config() {
5565 let tmp = tempfile::tempdir().unwrap();
5566 let cfg = tmp.path().join(".orch.yml");
5567 std::fs::write(
5568 &cfg,
5569 "jobs:\n - id: trading-scan\n schedule: \"0 9 * * *\"\n task:\n title: Scan\n body: do it\n",
5570 )
5571 .unwrap();
5572 let jobs_dir = tmp.path().join("prompts").join("jobs");
5573 std::fs::create_dir_all(&jobs_dir).unwrap();
5574 std::fs::write(
5575 jobs_dir.join("trading-scan.md"),
5576 "---\nid: trading-scan\nschedule: '0 9 * * *'\ntitle: Trading Scan\n---\n\nDo trading scan.\n",
5577 )
5578 .unwrap();
5579
5580 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5581 let result = tick_job_scheduler(&cfg, &backend, None, "test/repo", None).await;
5582 assert!(
5583 result.is_ok(),
5584 "duplicate job id must not propagate as Err: {result:?}"
5585 );
5586 }
5587
5588 #[tokio::test]
5592 async fn job_scheduler_processes_valid_project_when_other_has_duplicate() {
5593 let bad_tmp = tempfile::tempdir().unwrap();
5595 let bad_cfg = bad_tmp.path().join(".orch.yml");
5596 std::fs::write(
5597 &bad_cfg,
5598 "jobs:\n - id: dup\n schedule: \"0 9 * * *\"\n task:\n title: Dup\n body: x\n",
5599 )
5600 .unwrap();
5601 let bad_jobs_dir = bad_tmp.path().join("prompts").join("jobs");
5602 std::fs::create_dir_all(&bad_jobs_dir).unwrap();
5603 std::fs::write(
5604 bad_jobs_dir.join("dup.md"),
5605 "---\nid: dup\nschedule: '0 9 * * *'\ntitle: Dup\n---\n\nbody\n",
5606 )
5607 .unwrap();
5608
5609 let good_tmp = tempfile::tempdir().unwrap();
5611 let good_cfg = good_tmp.path().join(".orch.yml");
5612 std::fs::write(
5613 &good_cfg,
5614 "jobs:\n - id: valid-job\n schedule: \"0 9 * * *\"\n task:\n title: Valid\n body: ok\n",
5615 )
5616 .unwrap();
5617
5618 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5619
5620 let bad_result = tick_job_scheduler(&bad_cfg, &backend, None, "test/bad-repo", None).await;
5621 assert!(
5622 bad_result.is_ok(),
5623 "bad project must not abort scheduler: {bad_result:?}"
5624 );
5625
5626 let good_result =
5627 tick_job_scheduler(&good_cfg, &backend, None, "test/good-repo", None).await;
5628 assert!(
5629 good_result.is_ok(),
5630 "valid project must still run after bad project: {good_result:?}"
5631 );
5632 }
5633
5634 #[serial_test::serial(cooldown_state)]
5642 #[tokio::test]
5643 async fn global_sweep_routes_inactive_repo_tasks() {
5644 use crate::engine::router::{Router, RouterConfig};
5645 use crate::store::{TaskStatus, UpsertExternal};
5646
5647 crate::engine::cooldown::reset_global_state().await;
5648
5649 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5650
5651 let active_id = store
5653 .upsert_external(&UpsertExternal {
5654 repo: "active/repo",
5655 ext_id: "1",
5656 title: "Active task",
5657 body: "",
5658 author: "user",
5659 url: "https://github.com/active/repo/issues/1",
5660 labels: &[],
5661 origin: "github",
5662 })
5663 .await
5664 .unwrap();
5665
5666 let inactive_id = store
5668 .upsert_external(&UpsertExternal {
5669 repo: "inactive/repo",
5670 ext_id: "42",
5671 title: "Orphaned task",
5672 body: "",
5673 author: "user",
5674 url: "https://github.com/inactive/repo/issues/42",
5675 labels: &[],
5676 origin: "github",
5677 })
5678 .await
5679 .unwrap();
5680
5681 assert_eq!(store.get(active_id).await.unwrap().status, TaskStatus::New);
5682 assert_eq!(
5683 store.get(inactive_id).await.unwrap().status,
5684 TaskStatus::New
5685 );
5686
5687 let mut model_map: std::collections::HashMap<
5688 String,
5689 std::collections::HashMap<String, Vec<String>>,
5690 > = std::collections::HashMap::new();
5691 let mut tier = std::collections::HashMap::new();
5692 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5693 model_map.insert("medium".to_string(), tier);
5694 let config = RouterConfig {
5695 mode: "round_robin".to_string(),
5696 agents: vec!["claude".to_string()],
5697 model_map,
5698 ..Default::default()
5699 };
5700 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5701
5702 let active_repos: std::collections::HashSet<String> =
5703 ["active/repo".to_string()].into_iter().collect();
5704 let mut routing_quota = RoutingTickQuota::new(1);
5705 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5706 .await
5707 .unwrap();
5708
5709 assert_eq!(
5711 store.get(active_id).await.unwrap().status,
5712 TaskStatus::New,
5713 "global sweep must not touch tasks for the active repo"
5714 );
5715
5716 let after = store.get(inactive_id).await.unwrap();
5718 assert_eq!(
5719 after.status,
5720 TaskStatus::Routed,
5721 "global sweep must route tasks from inactive repos"
5722 );
5723 assert!(
5724 matches!(&after.agent, Some(a) if !a.is_empty()),
5725 "agent must be set after routing"
5726 );
5727 }
5728
5729 #[serial_test::serial(cooldown_state)]
5737 #[tokio::test]
5738 async fn global_sweep_does_not_touch_other_active_repo_tasks() {
5739 use crate::engine::router::{Router, RouterConfig};
5740 use crate::store::{TaskStatus, UpsertExternal};
5741
5742 crate::engine::cooldown::reset_global_state().await;
5743
5744 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5745
5746 let other_active_id = store
5749 .upsert_external(&UpsertExternal {
5750 repo: "other-active/repo",
5751 ext_id: "7",
5752 title: "Other active project's task",
5753 body: "",
5754 author: "user",
5755 url: "https://github.com/other-active/repo/issues/7",
5756 labels: &[],
5757 origin: "github",
5758 })
5759 .await
5760 .unwrap();
5761
5762 let inactive_id = store
5764 .upsert_external(&UpsertExternal {
5765 repo: "inactive/repo",
5766 ext_id: "42",
5767 title: "Orphaned task",
5768 body: "",
5769 author: "user",
5770 url: "https://github.com/inactive/repo/issues/42",
5771 labels: &[],
5772 origin: "github",
5773 })
5774 .await
5775 .unwrap();
5776
5777 let mut model_map: std::collections::HashMap<
5778 String,
5779 std::collections::HashMap<String, Vec<String>>,
5780 > = std::collections::HashMap::new();
5781 let mut tier = std::collections::HashMap::new();
5782 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5783 model_map.insert("medium".to_string(), tier);
5784 let config = RouterConfig {
5785 mode: "round_robin".to_string(),
5786 agents: vec!["claude".to_string()],
5787 model_map,
5788 ..Default::default()
5789 };
5790 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5791
5792 let active_repos: std::collections::HashSet<String> =
5796 ["active/repo".to_string(), "other-active/repo".to_string()]
5797 .into_iter()
5798 .collect();
5799 let mut routing_quota = RoutingTickQuota::new(1);
5800 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5801 .await
5802 .unwrap();
5803
5804 assert_eq!(
5805 store.get(other_active_id).await.unwrap().status,
5806 TaskStatus::New,
5807 "global sweep run from a different active repo's tick must not route \
5808 another active repo's new tasks — that repo's own tick will route them"
5809 );
5810 assert_eq!(
5811 store.get(inactive_id).await.unwrap().status,
5812 TaskStatus::Routed,
5813 "global sweep must still route tasks from genuinely orphaned repos"
5814 );
5815 }
5816
5817 #[serial_test::serial(cooldown_state)]
5819 #[tokio::test]
5820 async fn global_sweep_skips_no_agent_tasks() {
5821 use crate::engine::router::{Router, RouterConfig};
5822 use crate::store::{TaskStatus, UpsertExternal};
5823
5824 crate::engine::cooldown::reset_global_state().await;
5825
5826 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5827
5828 let id = store
5829 .upsert_external(&UpsertExternal {
5830 repo: "inactive/repo",
5831 ext_id: "99",
5832 title: "No-agent task",
5833 body: "",
5834 author: "user",
5835 url: "https://github.com/inactive/repo/issues/99",
5836 labels: &["no-agent".to_string()],
5837 origin: "github",
5838 })
5839 .await
5840 .unwrap();
5841
5842 let mut model_map: std::collections::HashMap<
5843 String,
5844 std::collections::HashMap<String, Vec<String>>,
5845 > = std::collections::HashMap::new();
5846 let mut tier = std::collections::HashMap::new();
5847 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5848 model_map.insert("medium".to_string(), tier);
5849 let config = RouterConfig {
5850 mode: "round_robin".to_string(),
5851 agents: vec!["claude".to_string()],
5852 model_map,
5853 ..Default::default()
5854 };
5855 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5856
5857 let active_repos: std::collections::HashSet<String> =
5858 ["active/repo".to_string()].into_iter().collect();
5859 let mut routing_quota = RoutingTickQuota::new(1);
5860 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5861 .await
5862 .unwrap();
5863
5864 assert_eq!(
5865 store.get(id).await.unwrap().status,
5866 TaskStatus::New,
5867 "no-agent tasks must not be routed"
5868 );
5869 }
5870
5871 #[serial_test::serial(cooldown_state)]
5876 #[tokio::test]
5877 async fn global_sweep_honors_shared_routing_quota() {
5878 use crate::engine::router::{Router, RouterConfig};
5879 use crate::store::{TaskStatus, UpsertExternal};
5880
5881 crate::engine::cooldown::reset_global_state().await;
5882
5883 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5884 for ext_id in ["1", "2"] {
5885 store
5886 .upsert_external(&UpsertExternal {
5887 repo: "inactive/repo",
5888 ext_id,
5889 title: "Orphaned task",
5890 body: "",
5891 author: "user",
5892 url: &format!("https://github.com/inactive/repo/issues/{ext_id}"),
5893 labels: &[],
5894 origin: "github",
5895 })
5896 .await
5897 .unwrap();
5898 }
5899
5900 let mut model_map: std::collections::HashMap<
5901 String,
5902 std::collections::HashMap<String, Vec<String>>,
5903 > = std::collections::HashMap::new();
5904 let mut tier = std::collections::HashMap::new();
5905 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5906 model_map.insert("medium".to_string(), tier);
5907 let config = RouterConfig {
5908 mode: "round_robin".to_string(),
5909 agents: vec!["claude".to_string()],
5910 model_map,
5911 ..Default::default()
5912 };
5913 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5914
5915 let active_repos: std::collections::HashSet<String> =
5916 ["active/repo".to_string()].into_iter().collect();
5917 let mut routing_quota = RoutingTickQuota::new(1);
5918 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5919 .await
5920 .unwrap();
5921
5922 let all_tasks = store.list_all_global().await.unwrap();
5923 let routed = all_tasks
5924 .iter()
5925 .filter(|task| task.status == TaskStatus::Routed)
5926 .count();
5927 let still_new = all_tasks
5928 .iter()
5929 .filter(|task| task.status == TaskStatus::New)
5930 .count();
5931
5932 assert_eq!(routed, 1, "only one task may consume the shared quota");
5933 assert_eq!(still_new, 1, "remaining tasks wait for the next tick");
5934
5935 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5936 .await
5937 .unwrap();
5938 let after_exhausted = store.list_all_global().await.unwrap();
5939 assert_eq!(
5940 after_exhausted
5941 .iter()
5942 .filter(|task| task.status == TaskStatus::Routed)
5943 .count(),
5944 1,
5945 "an exhausted shared quota must not reset within the same tick"
5946 );
5947 }
5948
5949 #[tokio::test]
5966 async fn group_orphaned_routed_tasks_selects_inactive_repo_tasks() {
5967 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
5968
5969 async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
5970 let id = store
5971 .upsert_external(&UpsertExternal {
5972 repo,
5973 ext_id,
5974 title: "Routed task",
5975 body: "",
5976 author: "user",
5977 url: &format!("https://github.com/{repo}/issues/{ext_id}"),
5978 labels: &[],
5979 origin: "github",
5980 })
5981 .await
5982 .unwrap();
5983 store
5984 .store_route(&StoreRoute {
5985 id,
5986 agent: "claude",
5987 model: Some("sonnet"),
5988 complexity: "medium",
5989 estimate: 0,
5990 reason: "test",
5991 profile: "{}",
5992 skills: "[]",
5993 })
5994 .await
5995 .unwrap();
5996 store.update_status(id, TaskStatus::Routed).await.unwrap();
5997 id
5998 }
5999
6000 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6001
6002 let active_id = make_routed(&store, "active/repo", "1").await;
6003 let inactive_id_a = make_routed(&store, "inactive/repo", "2").await;
6004 let other_inactive_id = make_routed(&store, "other-inactive/repo", "3").await;
6005 let inactive_id_b = make_routed(&store, "inactive/repo", "4").await;
6006
6007 let all_routed = store
6008 .list_all_by_status_global(TaskStatus::Routed)
6009 .await
6010 .unwrap();
6011 let active_repos: std::collections::HashSet<String> =
6012 ["active/repo".to_string()].into_iter().collect();
6013 let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
6014
6015 assert!(
6016 !by_repo.contains_key("active/repo"),
6017 "global sweep must not select tasks for the active repo"
6018 );
6019 let inactive_ids: std::collections::HashSet<i64> = by_repo
6020 .get("inactive/repo")
6021 .expect("inactive/repo must be selected")
6022 .iter()
6023 .map(|t| t.id)
6024 .collect();
6025 assert_eq!(
6026 inactive_ids,
6027 [inactive_id_a, inactive_id_b].into_iter().collect(),
6028 "global sweep must group all orphaned tasks for a repo together"
6029 );
6030 assert_eq!(
6031 by_repo
6032 .get("other-inactive/repo")
6033 .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
6034 Some(vec![other_inactive_id])
6035 );
6036
6037 assert_eq!(
6040 store.get(active_id).await.unwrap().status,
6041 TaskStatus::Routed
6042 );
6043 }
6044
6045 #[tokio::test]
6050 async fn group_orphaned_routed_tasks_excludes_all_active_repos_not_just_current() {
6051 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
6052
6053 async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
6054 let id = store
6055 .upsert_external(&UpsertExternal {
6056 repo,
6057 ext_id,
6058 title: "Routed task",
6059 body: "",
6060 author: "user",
6061 url: &format!("https://github.com/{repo}/issues/{ext_id}"),
6062 labels: &[],
6063 origin: "github",
6064 })
6065 .await
6066 .unwrap();
6067 store
6068 .store_route(&StoreRoute {
6069 id,
6070 agent: "claude",
6071 model: Some("sonnet"),
6072 complexity: "medium",
6073 estimate: 0,
6074 reason: "test",
6075 profile: "{}",
6076 skills: "[]",
6077 })
6078 .await
6079 .unwrap();
6080 store.update_status(id, TaskStatus::Routed).await.unwrap();
6081 id
6082 }
6083
6084 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6085
6086 let repo_a_id = make_routed(&store, "active/repo-a", "1").await;
6087 let repo_b_id = make_routed(&store, "active/repo-b", "2").await;
6088 let orphaned_id = make_routed(&store, "inactive/repo", "3").await;
6089
6090 let all_routed = store
6091 .list_all_by_status_global(TaskStatus::Routed)
6092 .await
6093 .unwrap();
6094 let active_repos: std::collections::HashSet<String> =
6097 ["active/repo-a".to_string(), "active/repo-b".to_string()]
6098 .into_iter()
6099 .collect();
6100 let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
6101
6102 assert!(
6103 !by_repo.contains_key("active/repo-a"),
6104 "the current repo's own tasks must not be swept"
6105 );
6106 assert!(
6107 !by_repo.contains_key("active/repo-b"),
6108 "another active repo's tasks must not be swept during repo A's tick"
6109 );
6110 assert_eq!(
6111 by_repo
6112 .get("inactive/repo")
6113 .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
6114 Some(vec![orphaned_id]),
6115 "a genuinely inactive repo's tasks must still be swept"
6116 );
6117
6118 assert_eq!(
6120 store.get(repo_a_id).await.unwrap().status,
6121 TaskStatus::Routed
6122 );
6123 assert_eq!(
6124 store.get(repo_b_id).await.unwrap().status,
6125 TaskStatus::Routed
6126 );
6127 }
6128
6129 #[tokio::test]
6131 async fn global_dispatch_sweep_skips_no_agent_tasks() {
6132 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
6133
6134 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6135
6136 let id = store
6137 .upsert_external(&UpsertExternal {
6138 repo: "inactive/repo",
6139 ext_id: "99",
6140 title: "No-agent routed task",
6141 body: "",
6142 author: "user",
6143 url: "https://github.com/inactive/repo/issues/99",
6144 labels: &["no-agent".to_string()],
6145 origin: "github",
6146 })
6147 .await
6148 .unwrap();
6149 store
6150 .store_route(&StoreRoute {
6151 id,
6152 agent: "claude",
6153 model: Some("sonnet"),
6154 complexity: "medium",
6155 estimate: 0,
6156 reason: "test",
6157 profile: "{}",
6158 skills: "[]",
6159 })
6160 .await
6161 .unwrap();
6162 store.update_status(id, TaskStatus::Routed).await.unwrap();
6163
6164 let tmux = Arc::new(TmuxManager::new());
6169 let transport = Arc::new(Transport::new());
6170 let capture = Arc::new(CaptureService::new(transport));
6171 let semaphore = Arc::new(Semaphore::new(4));
6172 let (weight_tx, _weight_rx) = mpsc::channel(16);
6173 let dispatching = Arc::new(DashMap::new());
6174 let dispatch_mode = DispatchMode {
6175 is_degraded: false,
6176 healthy_agents: 1,
6177 threshold: 1,
6178 };
6179
6180 let active_repos: std::collections::HashSet<String> =
6181 ["active/repo".to_string()].into_iter().collect();
6182 dispatch_routed_tasks_global(
6183 &tmux,
6184 &capture,
6185 &semaphore,
6186 &weight_tx,
6187 dispatch_mode,
6188 &dispatching,
6189 &store,
6190 &std::collections::HashMap::new(),
6191 &active_repos,
6192 )
6193 .await
6194 .unwrap();
6195
6196 assert_eq!(
6197 store.get(id).await.unwrap().status,
6198 TaskStatus::Routed,
6199 "no-agent tasks must not be dispatched"
6200 );
6201 }
6202}