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