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