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
3110pub(crate) fn display_status_for(
3113 signal: &WeightSignal,
3114 enable_review: bool,
3115 has_pr: bool,
3116) -> &'static str {
3117 match signal {
3118 WeightSignal::Success { .. } if enable_review => {
3119 if has_pr {
3120 "needs_review"
3121 } else {
3122 "done"
3123 }
3124 }
3125 WeightSignal::RateLimited { .. } | WeightSignal::Rerouted => "new",
3128 WeightSignal::Blocked => "blocked",
3129 WeightSignal::Failed { .. } => "needs_review",
3131 WeightSignal::None if enable_review => "needs_review",
3133 WeightSignal::Success { .. } | WeightSignal::None => "done",
3134 }
3135}
3136
3137#[cfg(test)]
3138mod tests {
3139 use super::*;
3140 use crate::backends::{ExternalId, ExternalTask, Mention, Status};
3141 use crate::channels::transport::Transport;
3142 use crate::engine::RunTimeouts;
3143 use crate::engine::cooldown;
3144 use crate::engine::router::Router;
3145 use crate::engine::router::RouterConfig;
3146 use crate::engine::tasks::TaskManager;
3147 use crate::store::DepSource;
3148 use crate::store::NewDependency;
3149 use crate::store::NewTask;
3150 use crate::store::StoreRoute;
3151 use crate::store::TaskStatus as DbStatus;
3152 use crate::store::TaskStatus;
3153 use crate::store::TaskStore;
3154 use crate::store::UpsertExternal;
3155 use crate::tmux::{SessionGuard, TmuxManager};
3156 use async_trait::async_trait;
3157 use dashmap::mapref::entry::Entry;
3158 use std::fs;
3159 use std::path::PathBuf;
3160 use std::sync::{Arc, Mutex};
3161 use tokio::sync::RwLock;
3162 use tokio::sync::oneshot;
3163 use tokio::task;
3164
3165 #[test]
3166 fn timed_out_run_without_pr_never_becomes_done() {
3167 let failed = WeightSignal::Failed {
3168 agent: "claude".to_string(),
3169 };
3170 assert_eq!(display_status_for(&failed, true, false), "needs_review");
3171 assert_eq!(display_status_for(&failed, false, false), "needs_review");
3172 }
3173
3174 #[test]
3175 fn done_needs_a_successful_run_with_no_pr() {
3176 let ok = WeightSignal::Success {
3177 agent: "claude".to_string(),
3178 };
3179 assert_eq!(display_status_for(&ok, true, true), "needs_review");
3180 assert_eq!(display_status_for(&ok, true, false), "done");
3181 assert_eq!(
3182 display_status_for(&WeightSignal::Rerouted, true, false),
3183 "new"
3184 );
3185 assert_eq!(
3186 display_status_for(&WeightSignal::None, true, false),
3187 "needs_review"
3188 );
3189 }
3190 #[test]
3191 fn skips_agent_for_human_and_no_agent_labels() {
3192 assert!(skips_agent(&["owner:human".to_string()]));
3193 assert!(skips_agent(&["no-agent".to_string()]));
3194 assert!(!skips_agent(&["status:new".to_string()]));
3195 }
3196
3197 fn cooled_review_router_for_test() -> Router {
3201 Router::new_for_test(RouterConfig::default(), vec!["claude".to_string()])
3202 }
3203
3204 struct MockBackend {
3208 blocked_tasks: Vec<ExternalTask>,
3210 sub_issues: HashMap<String, Vec<ExternalId>>,
3212 tasks_by_id: HashMap<String, ExternalTask>,
3214 get_sub_issues_calls: Arc<Mutex<Vec<String>>>,
3216 status_updates: Arc<Mutex<Vec<(String, Status)>>>,
3218 removed_labels: Arc<Mutex<Vec<(String, String)>>>,
3220 posted_comments: Arc<Mutex<Vec<(String, String)>>>,
3222 }
3223
3224 impl MockBackend {
3225 fn new() -> Self {
3226 Self {
3227 blocked_tasks: vec![],
3228 sub_issues: HashMap::new(),
3229 tasks_by_id: HashMap::new(),
3230 get_sub_issues_calls: Arc::new(Mutex::new(vec![])),
3231 status_updates: Arc::new(Mutex::new(vec![])),
3232 removed_labels: Arc::new(Mutex::new(vec![])),
3233 posted_comments: Arc::new(Mutex::new(vec![])),
3234 }
3235 }
3236 }
3237
3238 fn make_task(id: &str, labels: &[&str]) -> ExternalTask {
3239 ExternalTask {
3240 id: ExternalId(id.to_string()),
3241 title: format!("Task {id}"),
3242 body: String::new(),
3243 state: "open".to_string(),
3244 labels: labels.iter().map(ToString::to_string).collect(),
3245 author: "bot".to_string(),
3246 created_at: "2026-01-01T00:00:00Z".to_string(),
3247 updated_at: "2026-01-01T00:00:00Z".to_string(),
3248 url: format!("https://github.com/test/test/issues/{id}"),
3249 }
3250 }
3251
3252 #[async_trait]
3253 impl ExternalBackend for MockBackend {
3254 fn name(&self) -> &'static str {
3255 "mock"
3256 }
3257 async fn create_task(
3258 &self,
3259 _title: &str,
3260 _body: &str,
3261 _labels: &[String],
3262 ) -> anyhow::Result<ExternalId> {
3263 Ok(ExternalId("new".to_string()))
3264 }
3265 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
3266 self.tasks_by_id
3267 .get(&id.0)
3268 .cloned()
3269 .ok_or_else(|| anyhow::anyhow!("task not found: {}", id.0))
3270 }
3271 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
3272 if status == Status::Blocked {
3273 Ok(self.blocked_tasks.clone())
3274 } else {
3275 Ok(vec![])
3276 }
3277 }
3278 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
3279 Ok(vec![])
3280 }
3281 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
3282 self.posted_comments
3283 .lock()
3284 .unwrap()
3285 .push((_id.0.clone(), _body.to_string()));
3286 Ok(())
3287 }
3288 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
3289 Ok(())
3290 }
3291 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
3292 self.removed_labels
3293 .lock()
3294 .unwrap()
3295 .push((_id.0.clone(), _label.to_string()));
3296 Ok(())
3297 }
3298 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
3299 self.get_sub_issues_calls.lock().unwrap().push(id.0.clone());
3300 Ok(self.sub_issues.get(&id.0).cloned().unwrap_or_default())
3301 }
3302 async fn create_sub_task(
3303 &self,
3304 _parent: &ExternalId,
3305 _title: &str,
3306 _body: &str,
3307 _labels: &[String],
3308 ) -> anyhow::Result<ExternalId> {
3309 Ok(ExternalId("child".to_string()))
3310 }
3311 async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
3312 Ok(())
3313 }
3314 async fn has_open_issue_with_title(
3315 &self,
3316 _title: &str,
3317 _label: &str,
3318 ) -> anyhow::Result<bool> {
3319 Ok(false)
3320 }
3321 async fn health_check(&self) -> anyhow::Result<()> {
3322 Ok(())
3323 }
3324 async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
3325 Ok(false)
3326 }
3327 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
3328 Ok(Some("testbot".to_string()))
3329 }
3330 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
3331 Ok(vec![])
3332 }
3333 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
3334 self.status_updates
3335 .lock()
3336 .unwrap()
3337 .push((id.0.clone(), status));
3338 Ok(())
3339 }
3340 }
3341
3342 fn make_task_manager(backend: Arc<dyn ExternalBackend>) -> Arc<TaskManager> {
3343 Arc::new(TaskManager::new(backend))
3344 }
3345
3346 #[tokio::test]
3349 async fn unblock_parents_unblocks_when_all_children_done() {
3350 let mut mock = MockBackend::new();
3351
3352 let parent = make_task("10", &["status:blocked"]);
3354 mock.blocked_tasks.push(parent.clone());
3355 mock.sub_issues.insert(
3356 "10".to_string(),
3357 vec![ExternalId("11".to_string()), ExternalId("12".to_string())],
3358 );
3359 mock.tasks_by_id
3360 .insert("11".to_string(), make_task("11", &["status:done"]));
3361 mock.tasks_by_id
3362 .insert("12".to_string(), make_task("12", &["status:done"]));
3363
3364 let status_updates = Arc::clone(&mock.status_updates);
3365 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3366 let task_manager = make_task_manager(Arc::clone(&backend));
3367 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3368
3369 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3370 .await
3371 .unwrap();
3372
3373 let updates = status_updates.lock().unwrap();
3374 assert_eq!(updates.len(), 1, "parent should be unblocked");
3375 assert_eq!(updates[0], ("10".to_string(), Status::New));
3376 }
3377
3378 #[tokio::test]
3379 async fn unblock_parents_skips_when_child_not_done() {
3380 let mut mock = MockBackend::new();
3381
3382 let parent = make_task("20", &["status:blocked"]);
3383 mock.blocked_tasks.push(parent.clone());
3384 mock.sub_issues.insert(
3385 "20".to_string(),
3386 vec![ExternalId("21".to_string()), ExternalId("22".to_string())],
3387 );
3388 mock.tasks_by_id
3390 .insert("21".to_string(), make_task("21", &["status:done"]));
3391 mock.tasks_by_id
3392 .insert("22".to_string(), make_task("22", &["status:in_progress"]));
3393
3394 let status_updates = Arc::clone(&mock.status_updates);
3395 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3396 let task_manager = make_task_manager(Arc::clone(&backend));
3397 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3398
3399 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3400 .await
3401 .unwrap();
3402
3403 let updates = status_updates.lock().unwrap();
3404 assert!(
3405 updates.is_empty(),
3406 "should not unblock when a child is still running"
3407 );
3408 }
3409
3410 #[tokio::test]
3411 async fn unblock_parents_skips_task_with_no_children() {
3412 let mut mock = MockBackend::new();
3413
3414 let parent = make_task("30", &["status:blocked"]);
3416 mock.blocked_tasks.push(parent.clone());
3417 let status_updates = Arc::clone(&mock.status_updates);
3420 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3421 let task_manager = make_task_manager(Arc::clone(&backend));
3422 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3423
3424 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3425 .await
3426 .unwrap();
3427
3428 let updates = status_updates.lock().unwrap();
3429 assert!(
3430 updates.is_empty(),
3431 "should not unblock tasks with no sub-issues"
3432 );
3433 }
3434
3435 #[tokio::test]
3436 async fn unblock_parents_skips_task_with_block_reason_in_store() {
3437 let mut mock = MockBackend::new();
3440
3441 let parent = make_task("60", &["status:blocked"]);
3443 mock.blocked_tasks.push(parent.clone());
3444 mock.sub_issues
3447 .insert("60".to_string(), vec![ExternalId("61".to_string())]);
3448 mock.tasks_by_id
3449 .insert("61".to_string(), make_task("61", &["status:done"]));
3450
3451 let status_updates = Arc::clone(&mock.status_updates);
3452 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3453 let task_manager = make_task_manager(Arc::clone(&backend));
3454
3455 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3457 let task_id = store
3458 .upsert_external(&crate::store::UpsertExternal {
3459 repo: "test/repo",
3460 ext_id: "60",
3461 title: "Task 60",
3462 body: "",
3463 author: "bot",
3464 url: "https://github.com/test/test/issues/60",
3465 labels: &[],
3466 origin: "github",
3467 })
3468 .await
3469 .unwrap();
3470 store
3471 .update_status(task_id, TaskStatus::Blocked)
3472 .await
3473 .unwrap();
3474 store
3475 .set_block_reason(task_id, Some("CI failure limit reached"))
3476 .await
3477 .unwrap();
3478
3479 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3480 .await
3481 .unwrap();
3482
3483 let updates = status_updates.lock().unwrap();
3484 assert!(
3485 updates.is_empty(),
3486 "task with block_reason should be skipped — no API call, no unblock"
3487 );
3488 }
3489
3490 #[tokio::test]
3491 async fn unblock_parents_unblocks_internal_parent_from_store_children() {
3492 let mock = MockBackend::new();
3493 let get_sub_issues_calls = Arc::clone(&mock.get_sub_issues_calls);
3494 let status_updates = Arc::clone(&mock.status_updates);
3495 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3496 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3497 let task_manager = Arc::new(TaskManager::with_store(
3498 Arc::clone(&backend),
3499 Arc::clone(&store),
3500 "test/repo".to_string(),
3501 ));
3502
3503 let parent_id = store
3504 .create_internal("test/repo", "Parent", "", "job", "daily", None)
3505 .await
3506 .unwrap();
3507 store
3508 .update_status(parent_id, TaskStatus::Blocked)
3509 .await
3510 .unwrap();
3511 let child_id = store
3512 .create(&NewTask {
3513 external_id: Some("91".to_string()),
3514 repo: "test/repo".to_string(),
3515 origin: "github".to_string(),
3516 title: "Child".to_string(),
3517 body: String::new(),
3518 source: String::new(),
3519 source_id: String::new(),
3520 author: String::new(),
3521 url: String::new(),
3522 labels: vec![],
3523 parent_id: Some(parent_id),
3524 })
3525 .await
3526 .unwrap();
3527 store
3528 .update_status(child_id, TaskStatus::Done)
3529 .await
3530 .unwrap();
3531
3532 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3533 .await
3534 .unwrap();
3535
3536 let parent = store.get(parent_id).await.unwrap();
3537 assert_eq!(parent.status, TaskStatus::New);
3538 assert!(
3539 status_updates.lock().unwrap().is_empty(),
3540 "internal parent should be updated in store without backend mirroring"
3541 );
3542 assert!(
3543 get_sub_issues_calls.lock().unwrap().is_empty(),
3544 "internal parent should not query backend sub-issues"
3545 );
3546 }
3547
3548 #[cfg(test)]
3552 async fn set_task_updated_at_past(store: &TaskStore, task_id: i64) {
3553 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
3554 .bind(task_id)
3555 .execute(store.pool())
3556 .await
3557 .unwrap();
3558 }
3559
3560 async fn set_in_review_entered_at_past(store: &TaskStore, task_id: i64) {
3562 sqlx::query(
3563 "UPDATE task_activity SET timestamp = '2020-01-01T00:00:00Z'
3564 WHERE task_id = ? AND to_status = 'in_review'",
3565 )
3566 .bind(task_id)
3567 .execute(store.pool())
3568 .await
3569 .unwrap();
3570 }
3571
3572 #[tokio::test]
3573 async fn recover_stuck_tasks_resets_external_in_review_to_needs_review() {
3574 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3575 let mock = MockBackend::new();
3576 let status_updates = Arc::clone(&mock.status_updates);
3577 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3578 let task_manager = Arc::new(TaskManager::with_store(
3579 Arc::clone(&backend),
3580 Arc::clone(&store),
3581 "owner/repo".to_string(),
3582 ));
3583 let tmux = Arc::new(TmuxManager::new());
3584 let config = EngineConfig {
3585 no_session_stuck_timeout: 600,
3586 stuck_timeout: 1800,
3587 ..EngineConfig::default()
3588 };
3589
3590 let id = store
3592 .upsert_external(&crate::store::UpsertExternal {
3593 repo: "owner/repo",
3594 ext_id: "99",
3595 title: "Stuck InReview",
3596 body: "",
3597 author: "",
3598 url: "",
3599 labels: &[],
3600 origin: "github",
3601 })
3602 .await
3603 .unwrap();
3604 store.update_status(id, TaskStatus::InReview).await.unwrap();
3605 set_in_review_entered_at_past(&store, id).await;
3606 set_task_updated_at_past(&store, id).await;
3607
3608 tick_recover_stuck_tasks(
3609 &backend,
3610 &tmux,
3611 "owner/repo",
3612 &task_manager,
3613 &config,
3614 &store,
3615 &HashMap::new(),
3616 &Arc::new(DashMap::new()),
3617 )
3618 .await
3619 .unwrap();
3620
3621 let updates = status_updates.lock().unwrap();
3622 assert_eq!(updates.len(), 1, "stuck InReview task should be recovered");
3623 assert_eq!(updates[0].0, "99");
3624 assert_eq!(updates[0].1, Status::NeedsReview);
3625 }
3626
3627 #[tokio::test]
3628 async fn recover_stuck_tasks_skips_recent_in_review() {
3629 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3630 let mock = MockBackend::new();
3631 let status_updates = Arc::clone(&mock.status_updates);
3632 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3633 let task_manager = Arc::new(TaskManager::with_store(
3634 Arc::clone(&backend),
3635 Arc::clone(&store),
3636 "owner/repo".to_string(),
3637 ));
3638 let tmux = Arc::new(TmuxManager::new());
3639 let config = EngineConfig {
3640 no_session_stuck_timeout: 600, stuck_timeout: 1800,
3642 ..EngineConfig::default()
3643 };
3644
3645 let id = store
3647 .upsert_external(&crate::store::UpsertExternal {
3648 repo: "owner/repo",
3649 ext_id: "100",
3650 title: "Recent InReview",
3651 body: "",
3652 author: "",
3653 url: "",
3654 labels: &[],
3655 origin: "github",
3656 })
3657 .await
3658 .unwrap();
3659 store.update_status(id, TaskStatus::InReview).await.unwrap();
3660 set_task_updated_at_past(&store, id).await;
3661
3662 tick_recover_stuck_tasks(
3663 &backend,
3664 &tmux,
3665 "owner/repo",
3666 &task_manager,
3667 &config,
3668 &store,
3669 &HashMap::new(),
3670 &Arc::new(DashMap::new()),
3671 )
3672 .await
3673 .unwrap();
3674
3675 let updates = status_updates.lock().unwrap();
3676 assert!(
3677 updates.is_empty(),
3678 "recent InReview task should not be recovered"
3679 );
3680 }
3681
3682 #[tokio::test]
3683 async fn recover_stuck_tasks_resets_internal_in_review_to_needs_review() {
3684 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3685 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3686 let task_manager = Arc::new(TaskManager::with_store(
3687 Arc::clone(&backend),
3688 Arc::clone(&store),
3689 "owner/repo".to_string(),
3690 ));
3691 let tmux = Arc::new(TmuxManager::new());
3692 let config = EngineConfig {
3693 no_session_stuck_timeout: 600,
3694 stuck_timeout: 1800,
3695 ..EngineConfig::default()
3696 };
3697
3698 let id = store
3700 .create_internal("owner/repo", "Internal InReview", "", "cron", "1", None)
3701 .await
3702 .unwrap();
3703 store.update_status(id, TaskStatus::InReview).await.unwrap();
3704 set_in_review_entered_at_past(&store, id).await;
3705 set_task_updated_at_past(&store, id).await;
3706
3707 tick_recover_stuck_tasks(
3708 &backend,
3709 &tmux,
3710 "owner/repo",
3711 &task_manager,
3712 &config,
3713 &store,
3714 &HashMap::new(),
3715 &Arc::new(DashMap::new()),
3716 )
3717 .await
3718 .unwrap();
3719
3720 let task = store.get(id).await.unwrap();
3721 assert_eq!(
3722 task.status,
3723 TaskStatus::NeedsReview,
3724 "stuck internal InReview task should be reset to NeedsReview"
3725 );
3726 }
3727
3728 #[tokio::test]
3729 async fn recover_stuck_tasks_ages_in_review_from_status_entry_not_updated_at() {
3730 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3731 let mock = MockBackend::new();
3732 let status_updates = Arc::clone(&mock.status_updates);
3733 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3734 let task_manager = Arc::new(TaskManager::with_store(
3735 Arc::clone(&backend),
3736 Arc::clone(&store),
3737 "owner/repo".to_string(),
3738 ));
3739 let tmux = Arc::new(TmuxManager::new());
3740 let config = EngineConfig {
3741 no_session_stuck_timeout: 600,
3742 stuck_timeout: 1800,
3743 ..EngineConfig::default()
3744 };
3745
3746 let id = store
3747 .upsert_external(&crate::store::UpsertExternal {
3748 repo: "owner/repo",
3749 ext_id: "102",
3750 title: "Fresh updated_at InReview",
3751 body: "",
3752 author: "",
3753 url: "",
3754 labels: &[],
3755 origin: "github",
3756 })
3757 .await
3758 .unwrap();
3759 store.update_status(id, TaskStatus::InReview).await.unwrap();
3760 set_in_review_entered_at_past(&store, id).await;
3761 store
3762 .set_fields(id, &[("pr_number", serde_json::json!(42))])
3763 .await
3764 .unwrap();
3765
3766 tick_recover_stuck_tasks(
3767 &backend,
3768 &tmux,
3769 "owner/repo",
3770 &task_manager,
3771 &config,
3772 &store,
3773 &HashMap::new(),
3774 &Arc::new(DashMap::new()),
3775 )
3776 .await
3777 .unwrap();
3778
3779 let updates = status_updates.lock().unwrap();
3780 assert_eq!(
3781 updates.len(),
3782 1,
3783 "in_review stuck recovery must age from the status entry even when updated_at is fresh"
3784 );
3785 assert_eq!(updates[0].0, "102");
3786 assert_eq!(updates[0].1, Status::NeedsReview);
3787 }
3788
3789 async fn orphaned_review_status_after_recovery(with_decision: bool) -> TaskStatus {
3792 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3793 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3794 let task_manager = Arc::new(TaskManager::with_store(
3795 Arc::clone(&backend),
3796 Arc::clone(&store),
3797 "owner/repo".to_string(),
3798 ));
3799 let tmux = Arc::new(TmuxManager::new());
3800
3801 let id = store
3802 .upsert_external(&crate::store::UpsertExternal {
3803 repo: "owner/repo",
3804 ext_id: "55",
3805 title: "Orphaned review",
3806 body: "",
3807 author: "",
3808 url: "",
3809 labels: &[],
3810 origin: "github",
3811 })
3812 .await
3813 .unwrap();
3814 store.update_status(id, TaskStatus::InReview).await.unwrap();
3815 set_in_review_entered_at_past(&store, id).await;
3817 if with_decision {
3818 store::store_log_activity(
3819 &Some(Arc::clone(&store)),
3820 "owner/repo",
3821 "55",
3822 "review_decision",
3823 None,
3824 None,
3825 None::<&str>,
3826 None::<&str>,
3827 Some(&serde_json::json!({"decision": "approve"})),
3828 )
3829 .await;
3830 }
3831
3832 tick_recover_stuck_tasks(
3833 &backend,
3834 &tmux,
3835 "owner/repo",
3836 &task_manager,
3837 &EngineConfig::default(),
3838 &store,
3839 &HashMap::new(),
3840 &Arc::new(DashMap::new()),
3841 )
3842 .await
3843 .unwrap();
3844
3845 store.get(id).await.unwrap().status
3846 }
3847
3848 #[tokio::test]
3849 async fn stale_in_review_recovery_resets_orphaned_task_without_session() {
3850 assert_eq!(
3851 orphaned_review_status_after_recovery(false).await,
3852 TaskStatus::NeedsReview
3853 );
3854 }
3855
3856 #[tokio::test]
3857 async fn stale_in_review_recovery_leaves_decided_task_waiting_on_merge() {
3858 assert_eq!(
3859 orphaned_review_status_after_recovery(true).await,
3860 TaskStatus::InReview
3861 );
3862 }
3863
3864 #[tokio::test]
3865 async fn recover_stuck_tasks_resets_review_agent_failures() {
3866 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3867 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3868 let task_manager = Arc::new(TaskManager::with_store(
3869 Arc::clone(&backend),
3870 Arc::clone(&store),
3871 "owner/repo".to_string(),
3872 ));
3873 let tmux = Arc::new(TmuxManager::new());
3874
3875 let id = store
3876 .upsert_external(&crate::store::UpsertExternal {
3877 repo: "owner/repo",
3878 ext_id: "104",
3879 title: "Orphaned review with failures",
3880 body: "",
3881 author: "",
3882 url: "",
3883 labels: &[],
3884 origin: "github",
3885 })
3886 .await
3887 .unwrap();
3888 store.update_status(id, TaskStatus::InReview).await.unwrap();
3889 store
3890 .set_fields(id, &[("review_agent_failures", serde_json::json!(2))])
3891 .await
3892 .unwrap();
3893 set_in_review_entered_at_past(&store, id).await;
3894
3895 tick_recover_stuck_tasks(
3896 &backend,
3897 &tmux,
3898 "owner/repo",
3899 &task_manager,
3900 &EngineConfig::default(),
3901 &store,
3902 &HashMap::new(),
3903 &Arc::new(DashMap::new()),
3904 )
3905 .await
3906 .unwrap();
3907
3908 let task = store.get(id).await.unwrap();
3909 assert_eq!(task.status, TaskStatus::NeedsReview);
3910 assert_eq!(
3911 task.review_agent_failures, 0,
3912 "a dead review session is an infrastructure event, not an agent failure"
3913 );
3914 }
3915
3916 #[tokio::test]
3917 async fn recover_stuck_tasks_reclaims_hung_in_review_session() {
3918 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3919 let mock = MockBackend::new();
3920 let status_updates = Arc::clone(&mock.status_updates);
3921 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3922 let task_manager = Arc::new(TaskManager::with_store(
3923 Arc::clone(&backend),
3924 Arc::clone(&store),
3925 "owner/repo".to_string(),
3926 ));
3927 let tmux = Arc::new(TmuxManager::new());
3928 let config = EngineConfig {
3929 no_session_stuck_timeout: 600,
3930 stuck_timeout: 1800,
3931 ..EngineConfig::default()
3932 };
3933
3934 let id = store
3935 .upsert_external(&crate::store::UpsertExternal {
3936 repo: "owner/repo",
3937 ext_id: "103",
3938 title: "Hung review session",
3939 body: "",
3940 author: "",
3941 url: "",
3942 labels: &[],
3943 origin: "github",
3944 })
3945 .await
3946 .unwrap();
3947 store.update_status(id, TaskStatus::InReview).await.unwrap();
3948 set_in_review_entered_at_past(&store, id).await;
3949 store
3950 .set_fields(id, &[("pr_number", serde_json::json!(43))])
3951 .await
3952 .unwrap();
3953
3954 let session_name = tmux.session_name("owner/repo", "103-review");
3955 let mut session_map = HashMap::new();
3956 session_map.insert(session_name, true);
3957
3958 tick_recover_stuck_tasks(
3959 &backend,
3960 &tmux,
3961 "owner/repo",
3962 &task_manager,
3963 &config,
3964 &store,
3965 &session_map,
3966 &Arc::new(DashMap::new()),
3967 )
3968 .await
3969 .unwrap();
3970
3971 let updates = status_updates.lock().unwrap();
3972 assert_eq!(
3973 updates.len(),
3974 1,
3975 "a live hung review session must be reclaimed once the in_review entry ages past stuck_timeout"
3976 );
3977 assert_eq!(updates[0].0, "103");
3978 assert_eq!(updates[0].1, Status::NeedsReview);
3979 }
3980
3981 #[tokio::test]
3982 async fn untracked_repo_recovery_resets_stale_in_progress_to_new() {
3983 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3984 let tmux = Arc::new(TmuxManager::new());
3985 let config = EngineConfig {
3986 no_session_stuck_timeout: 600,
3987 stuck_timeout: 1800,
3988 ..EngineConfig::default()
3989 };
3990
3991 let id = store
3993 .upsert_external(&crate::store::UpsertExternal {
3994 repo: "owner/other",
3995 ext_id: "777",
3996 title: "Cross-repo stuck task",
3997 body: "",
3998 author: "",
3999 url: "",
4000 labels: &[],
4001 origin: "github",
4002 })
4003 .await
4004 .unwrap();
4005 store
4006 .update_status(id, TaskStatus::InProgress)
4007 .await
4008 .unwrap();
4009 set_task_updated_at_past(&store, id).await;
4010
4011 let active = HashSet::from(["owner/repo".to_string()]);
4012 recover_untracked_repo_stuck_tasks(
4013 &tmux,
4014 &config,
4015 &store,
4016 &HashMap::new(),
4017 &Arc::new(DashMap::new()),
4018 &active,
4019 )
4020 .await;
4021
4022 let task = store.get(id).await.unwrap();
4023 assert_eq!(
4024 task.status,
4025 TaskStatus::New,
4026 "cross-repo stale in_progress task should be reset to New"
4027 );
4028 assert_eq!(task.route_attempts, 0, "route attempts should be cleared");
4029 assert!(task.agent.is_none(), "agent should be cleared");
4030 assert!(task.model.is_none(), "model should be cleared");
4031 }
4032
4033 #[tokio::test]
4034 async fn untracked_repo_recovery_leaves_active_repos_to_their_own_pass() {
4035 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4036 let tmux = Arc::new(TmuxManager::new());
4037 let config = EngineConfig {
4038 no_session_stuck_timeout: 600,
4039 stuck_timeout: 1800,
4040 ..EngineConfig::default()
4041 };
4042 let id = store
4043 .upsert_external(&crate::store::UpsertExternal {
4044 repo: "owner/other",
4045 ext_id: "778",
4046 title: "Stuck task in an active repo",
4047 body: "",
4048 author: "",
4049 url: "",
4050 labels: &[],
4051 origin: "github",
4052 })
4053 .await
4054 .unwrap();
4055 store
4056 .update_status(id, TaskStatus::InProgress)
4057 .await
4058 .unwrap();
4059 set_task_updated_at_past(&store, id).await;
4060
4061 let active = HashSet::from(["owner/repo".to_string(), "owner/other".to_string()]);
4063 recover_untracked_repo_stuck_tasks(
4064 &tmux,
4065 &config,
4066 &store,
4067 &HashMap::new(),
4068 &Arc::new(DashMap::new()),
4069 &active,
4070 )
4071 .await;
4072
4073 assert_eq!(store.get(id).await.unwrap().status, TaskStatus::InProgress);
4074 }
4075
4076 #[tokio::test]
4077 async fn recover_stuck_tasks_finalizes_open_task_runs_rows() {
4078 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4079 let mock = MockBackend::new();
4080 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4081 let task_manager = Arc::new(TaskManager::with_store(
4082 Arc::clone(&backend),
4083 Arc::clone(&store),
4084 "owner/repo".to_string(),
4085 ));
4086 let tmux = Arc::new(TmuxManager::new());
4087 let config = EngineConfig {
4088 no_session_stuck_timeout: 600,
4089 stuck_timeout: 1800,
4090 ..EngineConfig::default()
4091 };
4092
4093 let id = store
4094 .create_internal("owner/repo", "Stuck with open run", "", "cron", "1", None)
4095 .await
4096 .unwrap();
4097 store
4098 .set_fields(
4099 id,
4100 &[
4101 ("agent", serde_json::json!("claude")),
4102 ("model", serde_json::json!("sonnet")),
4103 ("route_attempts", serde_json::json!(1)),
4104 ],
4105 )
4106 .await
4107 .unwrap();
4108 store
4109 .update_status(id, TaskStatus::InProgress)
4110 .await
4111 .unwrap();
4112 set_task_updated_at_past(&store, id).await;
4113
4114 store
4116 .start_run(&crate::store::StartRun {
4117 task_id: id,
4118 attempt: 1,
4119 run_type: "agent",
4120 agent: "claude",
4121 model: "sonnet",
4122 command: "claude --model sonnet",
4123 prompt: "fix it",
4124 })
4125 .await
4126 .unwrap();
4127
4128 tick_recover_stuck_tasks(
4129 &backend,
4130 &tmux,
4131 "owner/repo",
4132 &task_manager,
4133 &config,
4134 &store,
4135 &HashMap::new(),
4136 &Arc::new(DashMap::new()),
4137 )
4138 .await
4139 .unwrap();
4140
4141 let task = store.get(id).await.unwrap();
4142 assert_eq!(task.status, TaskStatus::New);
4143
4144 let runs = store.get_runs(id).await.unwrap();
4145 assert_eq!(runs.len(), 1);
4146 assert_eq!(runs[0].outcome, "aborted");
4147 assert!(runs[0].completed_at.is_some());
4148 assert_eq!(
4149 runs[0].error,
4150 "stuck-task recovery: internal no session found"
4151 );
4152 }
4153
4154 #[tokio::test]
4171 async fn recover_stuck_tasks_skips_reclaim_while_dispatch_guard_held() {
4172 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4173 let mock = MockBackend::new();
4174 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4175 let task_manager = Arc::new(TaskManager::with_store(
4176 Arc::clone(&backend),
4177 Arc::clone(&store),
4178 "owner/repo".to_string(),
4179 ));
4180 let tmux = Arc::new(TmuxManager::new());
4181 let config = EngineConfig {
4182 no_session_stuck_timeout: 600,
4183 stuck_timeout: 1800,
4184 ..EngineConfig::default()
4185 };
4186
4187 let id = store
4188 .create_internal(
4189 "owner/repo",
4190 "Long-running daily job",
4191 "",
4192 "cron",
4193 "1",
4194 None,
4195 )
4196 .await
4197 .unwrap();
4198 store
4199 .set_fields(
4200 id,
4201 &[
4202 ("agent", serde_json::json!("claude")),
4203 ("model", serde_json::json!("sonnet")),
4204 ("route_attempts", serde_json::json!(1)),
4205 ],
4206 )
4207 .await
4208 .unwrap();
4209 store
4210 .update_status(id, TaskStatus::InProgress)
4211 .await
4212 .unwrap();
4213 set_task_updated_at_past(&store, id).await;
4216
4217 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4220 dispatching.insert(
4221 format!("owner/repo/internal:{id}"),
4222 format!("internal:{id}"),
4223 );
4224
4225 tick_recover_stuck_tasks(
4226 &backend,
4227 &tmux,
4228 "owner/repo",
4229 &task_manager,
4230 &config,
4231 &store,
4232 &HashMap::new(),
4233 &dispatching,
4234 )
4235 .await
4236 .unwrap();
4237
4238 let task = store.get(id).await.unwrap();
4239 assert_eq!(
4240 task.status,
4241 TaskStatus::InProgress,
4242 "task should not be reclaimed while its dispatch guard is still held"
4243 );
4244
4245 let runs = store.get_runs(id).await.unwrap();
4247 assert!(runs.is_empty());
4248
4249 dispatching.clear();
4252 tick_recover_stuck_tasks(
4253 &backend,
4254 &tmux,
4255 "owner/repo",
4256 &task_manager,
4257 &config,
4258 &store,
4259 &HashMap::new(),
4260 &dispatching,
4261 )
4262 .await
4263 .unwrap();
4264 let task = store.get(id).await.unwrap();
4265 assert_eq!(
4266 task.status,
4267 TaskStatus::New,
4268 "task should still be reclaimed once the dispatch guard is released"
4269 );
4270 }
4271
4272 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
4288 async fn recover_stuck_tasks_never_reclaims_while_guard_held_under_real_concurrency() {
4289 const HAMMER_ITERATIONS: u32 = 300;
4290
4291 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4292 let mock = MockBackend::new();
4293 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4294 let task_manager = Arc::new(TaskManager::with_store(
4295 Arc::clone(&backend),
4296 Arc::clone(&store),
4297 "owner/repo".to_string(),
4298 ));
4299 let tmux = Arc::new(TmuxManager::new());
4300 let config = EngineConfig {
4301 no_session_stuck_timeout: 600,
4302 stuck_timeout: 1800,
4303 ..EngineConfig::default()
4304 };
4305
4306 let id = store
4307 .create_internal(
4308 "owner/repo",
4309 "Long-running daily job",
4310 "",
4311 "cron",
4312 "1",
4313 None,
4314 )
4315 .await
4316 .unwrap();
4317 store
4318 .set_fields(
4319 id,
4320 &[
4321 ("agent", serde_json::json!("claude")),
4322 ("model", serde_json::json!("sonnet")),
4323 ("route_attempts", serde_json::json!(1)),
4324 ],
4325 )
4326 .await
4327 .unwrap();
4328 store
4329 .update_status(id, TaskStatus::InProgress)
4330 .await
4331 .unwrap();
4332 set_task_updated_at_past(&store, id).await;
4333
4334 let dispatch_key = format!("owner/repo/internal:{id}");
4335 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4336
4337 let claim_task_id = format!("internal:{id}");
4344 {
4345 match dispatching.entry(dispatch_key.clone()) {
4346 Entry::Vacant(slot) => {
4347 slot.insert(claim_task_id);
4348 }
4349 Entry::Occupied(_) => panic!("dispatch key already claimed"),
4350 }
4351 }
4352 let guard = DispatchGuard::new(Arc::clone(&dispatching), dispatch_key.clone());
4353
4354 let (release_tx, release_rx) = oneshot::channel::<()>();
4365 let holder = tokio::spawn(async move {
4366 let _ = release_rx.await;
4367 drop(guard);
4368 });
4369
4370 for i in 0..HAMMER_ITERATIONS {
4378 tick_recover_stuck_tasks(
4379 &backend,
4380 &tmux,
4381 "owner/repo",
4382 &task_manager,
4383 &config,
4384 &store,
4385 &HashMap::new(),
4386 &dispatching,
4387 )
4388 .await
4389 .unwrap();
4390
4391 let task = store.get(id).await.unwrap();
4392 assert_eq!(
4393 task.status,
4394 TaskStatus::InProgress,
4395 "task must never be reclaimed while the dispatch guard is deterministically \
4396 still held, even under real cross-thread concurrency (dispatch_key={dispatch_key}, \
4397 iteration={i}, contains_key_now={}, map_len_now={})",
4398 dispatching.contains_key(&dispatch_key),
4399 dispatching.len(),
4400 );
4401 task::yield_now().await;
4402 }
4403
4404 let _ = release_tx.send(());
4406 holder.await.unwrap();
4407
4408 tick_recover_stuck_tasks(
4410 &backend,
4411 &tmux,
4412 "owner/repo",
4413 &task_manager,
4414 &config,
4415 &store,
4416 &HashMap::new(),
4417 &dispatching,
4418 )
4419 .await
4420 .unwrap();
4421 let task = store.get(id).await.unwrap();
4422 assert_eq!(
4423 task.status,
4424 TaskStatus::New,
4425 "task should be reclaimed once the dispatch guard is released"
4426 );
4427 }
4428
4429 #[tokio::test]
4430 async fn recover_stuck_tasks_skips_external_in_review_waiting_for_human_review() {
4431 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4432 let mock = MockBackend::new();
4433 let status_updates = Arc::clone(&mock.status_updates);
4434 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4435 let task_manager = Arc::new(TaskManager::with_store(
4436 Arc::clone(&backend),
4437 Arc::clone(&store),
4438 "owner/repo".to_string(),
4439 ));
4440 let tmux = Arc::new(TmuxManager::new());
4441 let config = EngineConfig {
4442 no_session_stuck_timeout: 600,
4443 stuck_timeout: 1800,
4444 ..EngineConfig::default()
4445 };
4446
4447 let id = store
4448 .upsert_external(&crate::store::UpsertExternal {
4449 repo: "owner/repo",
4450 ext_id: "101",
4451 title: "Waiting For Human Review",
4452 body: "",
4453 author: "",
4454 url: "",
4455 labels: &[],
4456 origin: "github",
4457 })
4458 .await
4459 .unwrap();
4460 store.update_status(id, TaskStatus::InReview).await.unwrap();
4461 set_task_updated_at_past(&store, id).await;
4462
4463 tick_recover_stuck_tasks(
4464 &backend,
4465 &tmux,
4466 "owner/repo",
4467 &task_manager,
4468 &config,
4469 &store,
4470 &HashMap::new(),
4471 &Arc::new(DashMap::new()),
4472 )
4473 .await
4474 .unwrap();
4475
4476 let updates = status_updates.lock().unwrap();
4477 assert!(
4478 updates.is_empty(),
4479 "InReview task waiting for human review should not be reset"
4480 );
4481 }
4482
4483 #[tokio::test]
4484 async fn unblock_parents_no_blocked_tasks() {
4485 let mock = MockBackend::new(); let status_updates = Arc::clone(&mock.status_updates);
4487 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4488 let task_manager = make_task_manager(Arc::clone(&backend));
4489 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4490
4491 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
4492 .await
4493 .unwrap();
4494
4495 assert!(status_updates.lock().unwrap().is_empty());
4496 }
4497
4498 #[tokio::test]
4499 async fn unblock_parents_handles_multiple_blocked_tasks() {
4500 let mut mock = MockBackend::new();
4501
4502 let t40 = make_task("40", &["status:blocked"]);
4504 mock.blocked_tasks.push(t40);
4505 mock.sub_issues
4506 .insert("40".to_string(), vec![ExternalId("41".to_string())]);
4507 mock.tasks_by_id
4508 .insert("41".to_string(), make_task("41", &["status:done"]));
4509
4510 let t50 = make_task("50", &["status:blocked"]);
4512 mock.blocked_tasks.push(t50);
4513 mock.sub_issues
4514 .insert("50".to_string(), vec![ExternalId("51".to_string())]);
4515 mock.tasks_by_id
4516 .insert("51".to_string(), make_task("51", &["status:in_progress"]));
4517
4518 let status_updates = Arc::clone(&mock.status_updates);
4519 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4520 let task_manager = make_task_manager(Arc::clone(&backend));
4521 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4522
4523 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
4524 .await
4525 .unwrap();
4526
4527 let updates = status_updates.lock().unwrap();
4528 assert_eq!(updates.len(), 1, "only one parent should be unblocked");
4529 assert_eq!(updates[0].0, "40", "task 40 should be unblocked");
4530 assert_eq!(updates[0].1, Status::New);
4531 }
4532
4533 #[tokio::test]
4536 async fn tick_all_is_a_no_op_without_projects() {
4537 let transport = Arc::new(Transport::new());
4538 let capture = Arc::new(CaptureService::new(Arc::clone(&transport)));
4539 let tmux = Arc::new(TmuxManager::new());
4540 let semaphore = Arc::new(Semaphore::new(1));
4541 let router = Arc::new(RwLock::new(cooled_review_router_for_test()));
4542 let (weight_tx, mut weight_rx) = mpsc::channel(8);
4543 let dispatching = Arc::new(DashMap::new());
4544 let auto_merge_in_flight = Arc::new(DashSet::new());
4545
4546 tick_all(
4547 &[],
4548 &tmux,
4549 &capture,
4550 &semaphore,
4551 &EngineConfig::default(),
4552 &router,
4553 &weight_tx,
4554 &dispatching,
4555 Some(&transport),
4556 &HashSet::new(),
4557 &auto_merge_in_flight,
4558 )
4559 .await;
4560
4561 assert!(dispatching.is_empty(), "no project, so no dispatch claimed");
4562 assert!(
4563 weight_rx.try_recv().is_err(),
4564 "no project, so no weight signal"
4565 );
4566 }
4567
4568 #[serial_test::serial(cooldown_state)]
4573 #[tokio::test]
4574 async fn tick_all_runs_the_global_needs_review_sweep_once_for_two_projects() {
4575 cooldown::reset_global_state().await;
4576
4577 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4578 let id = store
4579 .upsert_external(&UpsertExternal {
4580 repo: "owner/one",
4581 ext_id: "7",
4582 title: "Stale needs_review task",
4583 body: "",
4584 author: "user",
4585 url: "https://github.com/owner/one/issues/7",
4586 labels: &[],
4587 origin: "github",
4588 })
4589 .await
4590 .unwrap();
4591 store
4592 .update_status(id, TaskStatus::NeedsReview)
4593 .await
4594 .unwrap();
4595 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4596 .bind(id)
4597 .execute(store.pool())
4598 .await
4599 .unwrap();
4600
4601 let engines: Vec<ProjectEngine> = ["owner/one", "owner/two"]
4602 .iter()
4603 .map(|repo| {
4604 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4605 ProjectEngine {
4606 repo: repo.to_string(),
4607 project_dir: PathBuf::from("/nonexistent"),
4608 backend: Arc::clone(&backend),
4609 task_manager: Arc::new(TaskManager::with_store(
4610 backend,
4611 Arc::clone(&store),
4612 repo.to_string(),
4613 )),
4614 runner: Arc::new(TaskRunner::new(repo.to_string())),
4615 store: Arc::clone(&store),
4616 }
4617 })
4618 .collect();
4619
4620 let mut config = RouterConfig::default();
4622 config
4623 .model_map
4624 .entry("review".to_string())
4625 .or_default()
4626 .insert("claude".to_string(), vec!["sonnet".to_string()]);
4627 let router = Arc::new(RwLock::new(Router::new_for_test(
4628 config,
4629 vec!["claude".to_string()],
4630 )));
4631
4632 let transport = Arc::new(Transport::new());
4633 let capture = Arc::new(CaptureService::new(Arc::clone(&transport)));
4634 let (weight_tx, _weight_rx) = mpsc::channel(8);
4635
4636 tick_all(
4637 &engines,
4638 &Arc::new(TmuxManager::new()),
4639 &capture,
4640 &Arc::new(Semaphore::new(1)),
4641 &EngineConfig::default(),
4642 &router,
4643 &weight_tx,
4644 &Arc::new(DashMap::new()),
4645 Some(&transport),
4646 &engines.iter().map(|e| e.repo.clone()).collect(),
4647 &Arc::new(DashSet::new()),
4648 )
4649 .await;
4650
4651 assert_eq!(
4652 store.get(id).await.unwrap().needs_review_refires,
4653 1,
4654 "two projects must share one pass of the global NeedsReview sweep"
4655 );
4656 }
4657
4658 #[tokio::test]
4661 async fn tick_all_does_not_kill_sessions_it_did_not_create() {
4662 let tmux = Arc::new(TmuxManager::new());
4663 let Ok(session_name) = tmux
4664 .create_session("other/foreign", "3889", "/tmp", "sleep 30", &[])
4665 .await
4666 else {
4667 eprintln!("Skipping test: tmux not available or failed to create test session");
4668 return;
4669 };
4670 let _guard = SessionGuard(session_name.clone());
4671
4672 time::sleep(Duration::from_millis(1100)).await;
4675
4676 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4677 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4678 let engines = vec![ProjectEngine {
4679 repo: "owner/one".to_string(),
4680 project_dir: PathBuf::from("/nonexistent"),
4681 backend: Arc::clone(&backend),
4682 task_manager: Arc::new(TaskManager::with_store(
4683 backend,
4684 Arc::clone(&store),
4685 "owner/one".to_string(),
4686 )),
4687 runner: Arc::new(TaskRunner::new("owner/one".to_string())),
4688 store,
4689 }];
4690
4691 let transport = Arc::new(Transport::new());
4692 let capture = Arc::new(CaptureService::new(Arc::clone(&transport)));
4693 let (weight_tx, _weight_rx) = mpsc::channel(8);
4694 let router = Arc::new(RwLock::new(cooled_review_router_for_test()));
4695
4696 tick_all(
4697 &engines,
4698 &tmux,
4699 &capture,
4700 &Arc::new(Semaphore::new(1)),
4701 &EngineConfig::default(),
4702 &router,
4703 &weight_tx,
4704 &Arc::new(DashMap::new()),
4705 Some(&transport),
4706 &engines.iter().map(|e| e.repo.clone()).collect(),
4707 &Arc::new(DashSet::new()),
4708 )
4709 .await;
4710
4711 assert!(
4712 tmux.session_exists(&session_name).await,
4713 "tick_all must not kill a tmux session it did not start"
4714 );
4715 }
4716
4717 #[tokio::test]
4718 async fn detect_silent_agents_skips_backend_for_internal_tasks() {
4719 let transport = Arc::new(Transport::new());
4720 let capture = Arc::new(CaptureService::new(transport));
4721 let mock = Arc::new(MockBackend::new());
4722 let backend: Arc<dyn ExternalBackend> = Arc::<MockBackend>::clone(&mock);
4723 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4724 let task_manager = Arc::new(TaskManager::with_store(
4725 Arc::clone(&backend),
4726 Arc::clone(&store),
4727 "owner/repo".to_string(),
4728 ));
4729 let tmux = Arc::new(TmuxManager::new());
4730 let config = EngineConfig {
4731 silence_grace_period: 0,
4732 ..EngineConfig::default()
4733 };
4734
4735 let internal_id = store
4736 .create_internal("owner/repo", "Silent internal", "", "cron", "1", None)
4737 .await
4738 .unwrap();
4739 store
4740 .update_status(internal_id, TaskStatus::InProgress)
4741 .await
4742 .unwrap();
4743
4744 let task_id = format!("internal:{internal_id}");
4745 capture
4746 .register_session("owner/repo", &task_id, "orch-test-internal")
4747 .await;
4748 time::sleep(Duration::from_millis(1100)).await;
4749
4750 tick_detect_silent_agents(
4751 &tmux,
4752 "owner/repo",
4753 &capture,
4754 &backend,
4755 &task_manager,
4756 &config,
4757 &store,
4758 )
4759 .await
4760 .unwrap();
4761
4762 assert!(
4763 mock.removed_labels.lock().unwrap().is_empty(),
4764 "internal tasks should skip backend label removal"
4765 );
4766 assert!(
4767 mock.posted_comments.lock().unwrap().is_empty(),
4768 "internal tasks should skip backend comments"
4769 );
4770
4771 let task = store.get(internal_id).await.unwrap();
4772 assert!(
4776 matches!(task.status, TaskStatus::Routed | TaskStatus::New),
4777 "internal task should be routed to fallback or reset to new, got {:?}",
4778 task.status
4779 );
4780 }
4781
4782 #[tokio::test]
4783 async fn detect_silent_agents_finalizes_open_task_runs_rows() {
4784 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4785 let mock = MockBackend::new();
4786 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4787 let task_manager = Arc::new(TaskManager::with_store(
4788 Arc::clone(&backend),
4789 Arc::clone(&store),
4790 "owner/repo".to_string(),
4791 ));
4792 let transport = Arc::new(Transport::new());
4793 let capture = Arc::new(CaptureService::new(transport));
4794 let tmux = Arc::new(TmuxManager::new());
4795 let config = EngineConfig {
4796 silence_grace_period: 60,
4797 silence_cooldown: 300,
4798 ..EngineConfig::default()
4799 };
4800
4801 let internal_id = store
4802 .create_internal("owner/repo", "Silent with open run", "", "cron", "1", None)
4803 .await
4804 .unwrap();
4805 store
4806 .set_fields(
4807 internal_id,
4808 &[
4809 ("agent", serde_json::json!("claude")),
4810 ("model", serde_json::json!("sonnet")),
4811 ],
4812 )
4813 .await
4814 .unwrap();
4815 store
4816 .update_status(internal_id, TaskStatus::InProgress)
4817 .await
4818 .unwrap();
4819
4820 let task_id = format!("internal:{internal_id}");
4821 capture
4822 .register_session("owner/repo", &task_id, "orch-test-silent-run")
4823 .await;
4824 capture
4825 .set_buffer_state_for_test(
4826 "owner/repo",
4827 &task_id,
4828 false,
4829 false,
4830 chrono::Utc::now() - chrono::Duration::seconds(120),
4831 )
4832 .await;
4833
4834 store
4836 .start_run(&crate::store::StartRun {
4837 task_id: internal_id,
4838 attempt: 1,
4839 run_type: "agent",
4840 agent: "claude",
4841 model: "sonnet",
4842 command: "claude --model sonnet",
4843 prompt: "fix it",
4844 })
4845 .await
4846 .unwrap();
4847
4848 tick_detect_silent_agents(
4849 &tmux,
4850 "owner/repo",
4851 &capture,
4852 &backend,
4853 &task_manager,
4854 &config,
4855 &store,
4856 )
4857 .await
4858 .unwrap();
4859
4860 let runs = store.get_runs(internal_id).await.unwrap();
4861 assert_eq!(runs.len(), 1);
4862 assert_eq!(runs[0].outcome, "aborted");
4863 assert!(runs[0].completed_at.is_some());
4864 assert_eq!(runs[0].error, "silence detection: session killed");
4865 }
4866
4867 #[tokio::test]
4878 async fn phase1_session_completion_touch_prevents_phase2_reclaim() {
4879 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4880 let mock = MockBackend::new();
4881 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4882 let task_manager = Arc::new(TaskManager::with_store(
4883 Arc::clone(&backend),
4884 Arc::clone(&store),
4885 "owner/repo".to_string(),
4886 ));
4887 let tmux = Arc::new(TmuxManager::new());
4888 let config = EngineConfig {
4889 no_session_stuck_timeout: 600,
4890 stuck_timeout: 1800,
4891 ..EngineConfig::default()
4892 };
4893
4894 let id = store
4898 .create_internal("owner/repo", "Completed task", "", "cron", "1", None)
4899 .await
4900 .unwrap();
4901 store
4902 .update_status(id, TaskStatus::InProgress)
4903 .await
4904 .unwrap();
4905 set_task_updated_at_past(&store, id).await;
4906
4907 let task_id = format!("internal:{id}");
4910 store_touch_updated_at(&Some(Arc::clone(&store)), "owner/repo", &task_id).await;
4911
4912 tick_recover_stuck_tasks(
4914 &backend,
4915 &tmux,
4916 "owner/repo",
4917 &task_manager,
4918 &config,
4919 &store,
4920 &HashMap::new(),
4921 &Arc::new(DashMap::new()),
4922 )
4923 .await
4924 .unwrap();
4925
4926 let task = store.get(id).await.unwrap();
4927 assert_eq!(
4928 task.status,
4929 TaskStatus::InProgress,
4930 "Phase 2 must not reclaim a task whose updated_at was just touched by Phase 1"
4931 );
4932 }
4933
4934 #[tokio::test]
4935 async fn stuck_task_timing_treats_dead_session_as_missing() {
4936 let tmux = Arc::new(TmuxManager::new());
4937 let repo = "owner/repo";
4938 let task_id = "internal:33498";
4939 let session_name = tmux.session_name(repo, task_id);
4940
4941 let create_result = tmux
4942 .command()
4943 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
4944 .output()
4945 .await;
4946
4947 match create_result {
4948 Ok(o) if o.status.success() => {}
4949 _ => {
4950 eprintln!("Skipping test: tmux not available or failed to create test session");
4951 return;
4952 }
4953 }
4954
4955 let _guard = SessionGuard(session_name.clone());
4956
4957 let set_option_result = tmux
4958 .command()
4959 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
4960 .output()
4961 .await;
4962 if !matches!(set_option_result, Ok(ref o) if o.status.success()) {
4963 eprintln!("Skipping test: unable to set tmux remain-on-exit option");
4964 return;
4965 }
4966
4967 let send_exit_result = tmux
4968 .command()
4969 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
4970 .output()
4971 .await;
4972 if !matches!(send_exit_result, Ok(ref o) if o.status.success()) {
4973 eprintln!("Skipping test: unable to exit tmux pane");
4974 return;
4975 }
4976
4977 for _ in 0..20 {
4979 time::sleep(Duration::from_millis(100)).await;
4980 if !tmux.session_is_running(&session_name).await {
4981 break;
4982 }
4983 }
4984
4985 let mut session_map = tmux.batch_session_active().await;
4990 for _ in 0..20 {
4991 if !session_map.get(&session_name).copied().unwrap_or(false) {
4992 break;
4993 }
4994 time::sleep(Duration::from_millis(100)).await;
4995 session_map = tmux.batch_session_active().await;
4996 }
4997 if session_map.get(&session_name).copied().unwrap_or(false) {
4998 eprintln!(
4999 "Skipping test: session still alive in batch_session_active after retries (CI tmux lag)"
5000 );
5001 return;
5002 }
5003
5004 let config = EngineConfig {
5005 no_session_stuck_timeout: 600,
5006 stuck_timeout: 1800,
5007 ..EngineConfig::default()
5008 };
5009 let updated_at = (chrono::Utc::now()
5010 - chrono::Duration::seconds(
5011 i64::try_from(config.no_session_stuck_timeout).expect("timeout fits in i64") + 5,
5012 ))
5013 .to_rfc3339();
5014
5015 let timing = stuck_task_timing_from_map(
5016 &tmux,
5017 repo,
5018 task_id,
5019 &updated_at,
5020 &config,
5021 "parse failure",
5022 &session_map,
5023 )
5024 .expect("dead session should be treated as missing");
5025
5026 assert!(!timing.has_session);
5027 assert_eq!(timing.threshold, config.no_session_stuck_timeout);
5028 }
5029
5030 #[tokio::test]
5031 #[serial_test::serial(suspend_state)]
5032 async fn stuck_task_timing_discounts_host_suspend_gap() {
5033 super::super::suspend::clear_for_test();
5034
5035 let tmux = Arc::new(TmuxManager::new());
5036 let repo = "owner/repo";
5037 let task_id = "internal:99001";
5038 let session_map = HashMap::new();
5039
5040 let config = EngineConfig {
5041 no_session_stuck_timeout: 1800,
5042 stuck_timeout: 1800,
5043 ..EngineConfig::default()
5044 };
5045
5046 let updated_at = (chrono::Utc::now() - chrono::Duration::minutes(33)).to_rfc3339();
5050 let suspend_gap_detected_at = chrono::Utc::now() - chrono::Duration::minutes(1);
5051 super::super::suspend::inject_gap_for_test(
5052 suspend_gap_detected_at,
5053 chrono::Duration::minutes(30),
5054 );
5055
5056 let timing = stuck_task_timing_from_map(
5057 &tmux,
5058 repo,
5059 task_id,
5060 &updated_at,
5061 &config,
5062 "parse failure",
5063 &session_map,
5064 );
5065
5066 assert!(
5067 timing.is_none(),
5068 "task age should be discounted below the stuck threshold once suspend time is subtracted, got {timing:?}"
5069 );
5070
5071 super::super::suspend::clear_for_test();
5072 }
5073
5074 #[tokio::test]
5075 async fn recover_stuck_tasks_reclaims_routed_task_blocked_by_stale_session() {
5076 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5077 let mock = MockBackend::new();
5078 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5079 let task_manager = Arc::new(TaskManager::with_store(
5080 Arc::clone(&backend),
5081 Arc::clone(&store),
5082 "owner/repo".to_string(),
5083 ));
5084 let tmux = Arc::new(TmuxManager::new());
5085 let config = EngineConfig {
5086 no_session_stuck_timeout: 600,
5087 stuck_timeout: 1,
5088 ..EngineConfig::default()
5089 };
5090
5091 let id = store
5092 .create_internal("owner/repo", "Blocked routed task", "", "cron", "1", None)
5093 .await
5094 .unwrap();
5095 store.update_status(id, TaskStatus::Routed).await.unwrap();
5096 set_task_updated_at_past(&store, id).await;
5097
5098 let task_id = format!("internal:{id}");
5099 let session_name = tmux.session_name("owner/repo", &task_id);
5100 let create_result = tmux
5101 .command()
5102 .args([
5103 "new-session",
5104 "-d",
5105 "-s",
5106 &session_name,
5107 "-c",
5108 "/tmp",
5109 "sleep 60",
5110 ])
5111 .output()
5112 .await;
5113
5114 match create_result {
5115 Ok(o) if o.status.success() => {}
5116 _ => {
5117 eprintln!("Skipping test: tmux not available or failed to create test session");
5118 return;
5119 }
5120 }
5121
5122 let session_map = tmux.batch_session_active().await;
5123 assert_eq!(session_map.get(&session_name).copied(), Some(true));
5124
5125 tick_recover_stuck_tasks(
5126 &backend,
5127 &tmux,
5128 "owner/repo",
5129 &task_manager,
5130 &config,
5131 &store,
5132 &session_map,
5133 &Arc::new(DashMap::new()),
5134 )
5135 .await
5136 .unwrap();
5137
5138 let task = store.get(id).await.unwrap();
5139 assert_eq!(
5140 task.status,
5141 TaskStatus::New,
5142 "routed task blocked by a stale session should be reset for re-routing"
5143 );
5144 assert!(
5145 !tmux.session_exists(&session_name).await,
5146 "recovery should kill the stale tmux session so dispatch can proceed"
5147 );
5148 }
5149
5150 async fn live_complex_run(
5153 store: &TaskStore,
5154 tmux: &TmuxManager,
5155 minutes: i64,
5156 updated_minutes_ago: i64,
5157 ) -> Option<(i64, String)> {
5158 let id = store
5159 .create_internal("owner/repo", "Long complex run", "", "cron", "1", None)
5160 .await
5161 .unwrap();
5162 store
5163 .set_fields(id, &[("complexity", serde_json::json!("complex"))])
5164 .await
5165 .unwrap();
5166 store
5167 .update_status(id, TaskStatus::InProgress)
5168 .await
5169 .unwrap();
5170 let ago = |m: i64| {
5171 (chrono::Utc::now() - chrono::Duration::minutes(m))
5172 .format("%Y-%m-%dT%H:%M:%SZ")
5173 .to_string()
5174 };
5175 let entered = ago(minutes);
5176 sqlx::query(
5177 "UPDATE task_activity SET timestamp = ? WHERE task_id = ? AND to_status = 'in_progress'",
5178 )
5179 .bind(&entered)
5180 .bind(id)
5181 .execute(store.pool())
5182 .await
5183 .unwrap();
5184 sqlx::query("UPDATE tasks SET updated_at = ? WHERE id = ?")
5185 .bind(ago(updated_minutes_ago))
5186 .bind(id)
5187 .execute(store.pool())
5188 .await
5189 .unwrap();
5190
5191 let session_name = tmux.session_name("owner/repo", &format!("internal:{id}"));
5192 let created = tmux
5193 .command()
5194 .args([
5195 "new-session",
5196 "-d",
5197 "-s",
5198 &session_name,
5199 "-c",
5200 "/tmp",
5201 "sleep 60",
5202 ])
5203 .output()
5204 .await;
5205 match created {
5206 Ok(o) if o.status.success() => Some((id, session_name)),
5207 _ => None,
5208 }
5209 }
5210
5211 fn long_run_config() -> EngineConfig {
5212 EngineConfig {
5213 stuck_timeout: 1800,
5214 run_timeouts: RunTimeouts {
5215 base: 1800,
5216 medium: 2700,
5217 complex: 3600,
5218 },
5219 ..EngineConfig::default()
5220 }
5221 }
5222
5223 #[tokio::test]
5224 async fn recover_stuck_tasks_keeps_a_live_complex_run_inside_its_timeout() {
5225 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5226 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5227 let task_manager = Arc::new(TaskManager::with_store(
5228 Arc::clone(&backend),
5229 Arc::clone(&store),
5230 "owner/repo".to_string(),
5231 ));
5232 let tmux = Arc::new(TmuxManager::new());
5233 let Some((id, session_name)) = live_complex_run(&store, &tmux, 31, 31).await else {
5234 eprintln!("Skipping test: tmux not available or failed to create test session");
5235 return;
5236 };
5237 let session_map = tmux.batch_session_active().await;
5238
5239 tick_recover_stuck_tasks(
5240 &backend,
5241 &tmux,
5242 "owner/repo",
5243 &task_manager,
5244 &long_run_config(),
5245 &store,
5246 &session_map,
5247 &Arc::new(DashMap::new()),
5248 )
5249 .await
5250 .unwrap();
5251
5252 let alive = tmux.session_exists(&session_name).await;
5253 let _ = tmux.kill_session(&session_name).await;
5254 assert_eq!(
5255 store.get(id).await.unwrap().status,
5256 TaskStatus::InProgress,
5257 "a complex run at 31 minutes is inside its 3600s timeout"
5258 );
5259 assert!(alive, "recovery must not touch a run inside its timeout");
5260 }
5261
5262 #[tokio::test]
5263 async fn recover_stuck_tasks_kills_a_live_complex_run_past_its_timeout() {
5264 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5265 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5266 let task_manager = Arc::new(TaskManager::with_store(
5267 Arc::clone(&backend),
5268 Arc::clone(&store),
5269 "owner/repo".to_string(),
5270 ));
5271 let tmux = Arc::new(TmuxManager::new());
5272 let Some((id, session_name)) = live_complex_run(&store, &tmux, 66, 0).await else {
5273 eprintln!("Skipping test: tmux not available or failed to create test session");
5274 return;
5275 };
5276 let session_map = tmux.batch_session_active().await;
5277
5278 tick_recover_stuck_tasks(
5279 &backend,
5280 &tmux,
5281 "owner/repo",
5282 &task_manager,
5283 &long_run_config(),
5284 &store,
5285 &session_map,
5286 &Arc::new(DashMap::new()),
5287 )
5288 .await
5289 .unwrap();
5290
5291 let alive = tmux.session_exists(&session_name).await;
5292 let _ = tmux.kill_session(&session_name).await;
5293 assert_eq!(
5294 store.get(id).await.unwrap().status,
5295 TaskStatus::New,
5296 "a complex run past its timeout plus grace is stuck"
5297 );
5298 assert!(
5299 !alive,
5300 "recovery must kill the session it reports as killed"
5301 );
5302 }
5303
5304 #[tokio::test]
5318 async fn dispatch_does_not_deadlock_under_write_lock() {
5319 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5320 let mock = MockBackend::new();
5321 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5322 let task_manager = Arc::new(TaskManager::with_store(
5323 Arc::clone(&backend),
5324 Arc::clone(&store),
5325 "owner/repo".to_string(),
5326 ));
5327 let tmux = Arc::new(TmuxManager::new());
5328 let semaphore = Arc::new(Semaphore::new(4));
5329 let (weight_tx, _weight_rx) = mpsc::channel(16);
5330 let dispatching = Arc::new(DashMap::new());
5331 let transport = Arc::new(Transport::new());
5332 let capture = Arc::new(CaptureService::new(transport));
5333
5334 let router_config = RouterConfig::default();
5335 let router = Router::new_for_test(router_config, vec!["claude".to_string()]);
5336 let router_arc = Arc::new(RwLock::new(router));
5337
5338 let write_guard = router_arc.write().await;
5342 let dispatch_mode = dispatch_mode_from_router(&write_guard);
5343
5344 let result = time::timeout(
5345 Duration::from_secs(2),
5346 tick_dispatch_tasks(
5347 &backend,
5348 &tmux,
5349 "owner/repo",
5350 &Arc::new(TaskRunner::new("owner/repo".to_string())),
5351 &capture,
5352 &semaphore,
5353 &task_manager,
5354 &weight_tx,
5355 dispatch_mode,
5356 &dispatching,
5357 &store,
5358 &HashMap::new(),
5359 ),
5360 )
5361 .await;
5362
5363 assert!(
5364 result.is_ok(),
5365 "tick_dispatch_tasks deadlocked! It tried to acquire a read lock \
5366 on router_arc while a write lock was already held (issue #1361)"
5367 );
5368 }
5369
5370 #[tokio::test]
5378 async fn dispatch_skips_task_whose_routed_model_entered_cooldown() {
5379 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5380 let mock = MockBackend::new();
5381 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5382 let task_manager = Arc::new(TaskManager::with_store(
5383 Arc::clone(&backend),
5384 Arc::clone(&store),
5385 "owner/repo".to_string(),
5386 ));
5387 let tmux = Arc::new(TmuxManager::new());
5388 let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
5389 let semaphore = Arc::new(Semaphore::new(4));
5390 let (weight_tx, _weight_rx) = mpsc::channel(16);
5391 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5392 let transport = Arc::new(Transport::new());
5393 let capture = Arc::new(CaptureService::new(transport));
5394
5395 let id = store
5396 .upsert_external(&UpsertExternal {
5397 repo: "owner/repo",
5398 ext_id: "999",
5399 title: "Stale routed task",
5400 body: "",
5401 author: "user",
5402 url: "https://github.com/owner/repo/issues/999",
5403 labels: &[],
5404 origin: "github",
5405 })
5406 .await
5407 .unwrap();
5408 store
5409 .store_route(&StoreRoute {
5410 id,
5411 agent: "minimax",
5412 model: Some("opus"),
5413 complexity: "medium",
5414 estimate: 0,
5415 reason: "test",
5416 profile: "{}",
5417 skills: "[]",
5418 })
5419 .await
5420 .unwrap();
5421 store.update_status(id, DbStatus::Routed).await.unwrap();
5422
5423 cooldown::set_model_cooldown("minimax", "opus", 300, "rate_limit_usage_window").await;
5426
5427 let dispatch_mode = DispatchMode {
5428 is_degraded: false,
5429 healthy_agents: 1,
5430 threshold: 1,
5431 };
5432
5433 tick_dispatch_tasks(
5434 &backend,
5435 &tmux,
5436 "owner/repo",
5437 &runner,
5438 &capture,
5439 &semaphore,
5440 &task_manager,
5441 &weight_tx,
5442 dispatch_mode,
5443 &dispatching,
5444 &store,
5445 &HashMap::new(),
5446 )
5447 .await
5448 .unwrap();
5449
5450 let task = store.get(id).await.unwrap();
5451 assert_eq!(
5452 task.status,
5453 DbStatus::Routed,
5454 "task whose routed model is now cooled down must stay Routed, not be dispatched"
5455 );
5456 assert!(
5457 dispatching.is_empty(),
5458 "dispatch guard must be released when skipping a stale-cooldown task"
5459 );
5460 }
5461
5462 #[tokio::test]
5463 async fn dispatch_skips_a_task_with_an_open_dependency() {
5464 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5465 let mock = MockBackend::new();
5466 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5467 let task_manager = Arc::new(TaskManager::with_store(
5468 Arc::clone(&backend),
5469 Arc::clone(&store),
5470 "owner/repo".to_string(),
5471 ));
5472 let tmux = Arc::new(TmuxManager::new());
5473 let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
5474 let semaphore = Arc::new(Semaphore::new(4));
5475 let (weight_tx, _weight_rx) = mpsc::channel(16);
5476 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5477 let transport = Arc::new(Transport::new());
5478 let capture = Arc::new(CaptureService::new(transport));
5479
5480 let id = store
5481 .upsert_external(&UpsertExternal {
5482 repo: "owner/repo",
5483 ext_id: "42",
5484 title: "Waiting routed task",
5485 body: "",
5486 author: "user",
5487 url: "https://github.com/owner/repo/issues/42",
5488 labels: &[],
5489 origin: "github",
5490 })
5491 .await
5492 .unwrap();
5493 store
5494 .replace_dependencies(
5495 id,
5496 &[NewDependency {
5497 repo: "owner/repo".into(),
5498 number: 7,
5499 source: DepSource::Github,
5500 }],
5501 )
5502 .await
5503 .unwrap();
5504 store
5505 .store_route(&StoreRoute {
5506 id,
5507 agent: "claude",
5508 model: Some("haiku"),
5509 complexity: "simple",
5510 estimate: 0,
5511 reason: "test",
5512 profile: "{}",
5513 skills: "[]",
5514 })
5515 .await
5516 .unwrap();
5517 store.update_status(id, DbStatus::Routed).await.unwrap();
5518
5519 let dispatch_mode = DispatchMode {
5520 is_degraded: false,
5521 healthy_agents: 1,
5522 threshold: 1,
5523 };
5524 tick_dispatch_tasks(
5525 &backend,
5526 &tmux,
5527 "owner/repo",
5528 &runner,
5529 &capture,
5530 &semaphore,
5531 &task_manager,
5532 &weight_tx,
5533 dispatch_mode,
5534 &dispatching,
5535 &store,
5536 &HashMap::new(),
5537 )
5538 .await
5539 .unwrap();
5540
5541 let task = store.get(id).await.unwrap();
5542 assert_eq!(task.status, DbStatus::Waiting);
5543 assert!(dispatching.is_empty());
5544 }
5545
5546 #[tokio::test]
5559 async fn completed_session_does_not_cause_stuck_reclaim_race() {
5560 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5561 let mock = MockBackend::new();
5562 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5563 let task_manager = Arc::new(TaskManager::with_store(
5564 Arc::clone(&backend),
5565 Arc::clone(&store),
5566 "owner/repo".to_string(),
5567 ));
5568 let tmux = Arc::new(TmuxManager::new());
5569 let transport = Arc::new(Transport::new());
5570 let capture = Arc::new(CaptureService::new(transport));
5571
5572 let id = store
5574 .upsert_external(&crate::store::UpsertExternal {
5575 repo: "owner/repo",
5576 ext_id: "200",
5577 title: "Race Task",
5578 body: "",
5579 author: "",
5580 url: "",
5581 labels: &[],
5582 origin: "github",
5583 })
5584 .await
5585 .unwrap();
5586 store
5587 .update_status(id, TaskStatus::InProgress)
5588 .await
5589 .unwrap();
5590
5591 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
5593 .bind(id)
5594 .execute(store.pool())
5595 .await
5596 .unwrap();
5597
5598 let task_id = "200";
5600 let session_name = tmux.session_name("owner/repo", task_id);
5601 let create_result = tmux
5602 .command()
5603 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
5604 .output()
5605 .await;
5606 match create_result {
5607 Ok(o) if o.status.success() => {}
5608 _ => {
5609 eprintln!("Skipping test: tmux not available or failed to create test session");
5610 return;
5611 }
5612 }
5613 let _guard = SessionGuard(session_name.clone());
5614
5615 let _ = tmux
5617 .command()
5618 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
5619 .output()
5620 .await;
5621 let _ = tmux
5622 .command()
5623 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
5624 .output()
5625 .await;
5626
5627 for _ in 0..20 {
5629 time::sleep(Duration::from_millis(100)).await;
5630 if !tmux.session_is_running(&session_name).await {
5631 break;
5632 }
5633 }
5634
5635 let task_key = task_id.to_string();
5637 capture
5638 .register_session("owner/repo", &task_key, &session_name)
5639 .await;
5640
5641 let config = EngineConfig {
5643 no_session_stuck_timeout: 600,
5644 stuck_timeout: 1800,
5645 ..EngineConfig::default()
5646 };
5647
5648 tick_check_session_completions(&tmux, "owner/repo", &capture, &store)
5649 .await
5650 .unwrap();
5651
5652 {
5658 let task_state = store.get(id).await.unwrap();
5659 if task_state.updated_at.starts_with("2020") {
5660 eprintln!(
5661 "Skipping test: tick_check_session_completions did not update stored \
5662 updated_at — session was not in snapshot (CI tmux environment issue)"
5663 );
5664 return;
5665 }
5666 }
5667
5668 tick_recover_stuck_tasks(
5670 &backend,
5671 &tmux,
5672 "owner/repo",
5673 &task_manager,
5674 &config,
5675 &store,
5676 &HashMap::new(),
5677 &Arc::new(DashMap::new()),
5678 )
5679 .await
5680 .unwrap();
5681
5682 let task = store.get(id).await.unwrap();
5684 assert_eq!(
5685 task.status,
5686 TaskStatus::InProgress,
5687 "task should not be reclaimed to New"
5688 );
5689 }
5690
5691 #[tokio::test]
5701 async fn session_completion_matches_repo_configured_with_git_suffix() {
5702 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5703 let tmux = Arc::new(TmuxManager::new());
5704 let transport = Arc::new(Transport::new());
5705 let capture = Arc::new(CaptureService::new(transport));
5706
5707 let repo = "owner/repo.git";
5708 let id = store
5709 .upsert_external(&crate::store::UpsertExternal {
5710 repo,
5711 ext_id: "201",
5712 title: "Git-suffix repo task",
5713 body: "",
5714 author: "",
5715 url: "",
5716 labels: &[],
5717 origin: "github",
5718 })
5719 .await
5720 .unwrap();
5721 store
5722 .update_status(id, TaskStatus::InProgress)
5723 .await
5724 .unwrap();
5725 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
5726 .bind(id)
5727 .execute(store.pool())
5728 .await
5729 .unwrap();
5730
5731 let task_id = "201";
5734 let session_name = tmux.session_name(repo, task_id);
5735 let create_result = tmux
5736 .command()
5737 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
5738 .output()
5739 .await;
5740 match create_result {
5741 Ok(o) if o.status.success() => {}
5742 _ => {
5743 eprintln!("Skipping test: tmux not available or failed to create test session");
5744 return;
5745 }
5746 }
5747 let _guard = SessionGuard(session_name.clone());
5748
5749 let _ = tmux
5750 .command()
5751 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
5752 .output()
5753 .await;
5754 let _ = tmux
5755 .command()
5756 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
5757 .output()
5758 .await;
5759
5760 for _ in 0..20 {
5761 time::sleep(Duration::from_millis(100)).await;
5762 if !tmux.session_is_running(&session_name).await {
5763 break;
5764 }
5765 }
5766
5767 capture.register_session(repo, task_id, &session_name).await;
5768
5769 tick_check_session_completions(&tmux, repo, &capture, &store)
5770 .await
5771 .unwrap();
5772
5773 let task_state = store.get(id).await.unwrap();
5774 if task_state.updated_at.starts_with("2020") {
5775 eprintln!(
5776 "Skipping test: tick_check_session_completions did not update stored \
5777 updated_at — session was not in snapshot (CI tmux environment issue)"
5778 );
5779 return;
5780 }
5781 assert!(
5782 !task_state.updated_at.starts_with("2020"),
5783 "tick_check_session_completions must match its own repo's live sessions \
5784 even when the repo is configured with a \".git\" suffix"
5785 );
5786 }
5787
5788 #[tokio::test]
5794 async fn job_scheduler_returns_ok_on_duplicate_job_config() {
5795 let tmp = tempfile::tempdir().unwrap();
5796 let cfg = tmp.path().join(".orch.yml");
5797 fs::write(
5798 &cfg,
5799 "jobs:\n - id: trading-scan\n schedule: \"0 9 * * *\"\n task:\n title: Scan\n body: do it\n",
5800 )
5801 .unwrap();
5802 let jobs_dir = tmp.path().join("prompts").join("jobs");
5803 fs::create_dir_all(&jobs_dir).unwrap();
5804 fs::write(
5805 jobs_dir.join("trading-scan.md"),
5806 "---\nid: trading-scan\nschedule: '0 9 * * *'\ntitle: Trading Scan\n---\n\nDo trading scan.\n",
5807 )
5808 .unwrap();
5809
5810 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5811 let result = tick_job_scheduler(&cfg, &backend, None, "test/repo", None).await;
5812 assert!(
5813 result.is_ok(),
5814 "duplicate job id must not propagate as Err: {result:?}"
5815 );
5816 }
5817
5818 #[tokio::test]
5822 async fn job_scheduler_processes_valid_project_when_other_has_duplicate() {
5823 let bad_tmp = tempfile::tempdir().unwrap();
5825 let bad_cfg = bad_tmp.path().join(".orch.yml");
5826 fs::write(
5827 &bad_cfg,
5828 "jobs:\n - id: dup\n schedule: \"0 9 * * *\"\n task:\n title: Dup\n body: x\n",
5829 )
5830 .unwrap();
5831 let bad_jobs_dir = bad_tmp.path().join("prompts").join("jobs");
5832 fs::create_dir_all(&bad_jobs_dir).unwrap();
5833 fs::write(
5834 bad_jobs_dir.join("dup.md"),
5835 "---\nid: dup\nschedule: '0 9 * * *'\ntitle: Dup\n---\n\nbody\n",
5836 )
5837 .unwrap();
5838
5839 let good_tmp = tempfile::tempdir().unwrap();
5841 let good_cfg = good_tmp.path().join(".orch.yml");
5842 fs::write(
5843 &good_cfg,
5844 "jobs:\n - id: valid-job\n schedule: \"0 9 * * *\"\n task:\n title: Valid\n body: ok\n",
5845 )
5846 .unwrap();
5847
5848 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5849
5850 let bad_result = tick_job_scheduler(&bad_cfg, &backend, None, "test/bad-repo", None).await;
5851 assert!(
5852 bad_result.is_ok(),
5853 "bad project must not abort scheduler: {bad_result:?}"
5854 );
5855
5856 let good_result =
5857 tick_job_scheduler(&good_cfg, &backend, None, "test/good-repo", None).await;
5858 assert!(
5859 good_result.is_ok(),
5860 "valid project must still run after bad project: {good_result:?}"
5861 );
5862 }
5863
5864 #[serial_test::serial(cooldown_state)]
5872 #[tokio::test]
5873 async fn global_sweep_routes_inactive_repo_tasks() {
5874 cooldown::reset_global_state().await;
5875
5876 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5877
5878 let active_id = store
5880 .upsert_external(&UpsertExternal {
5881 repo: "active/repo",
5882 ext_id: "1",
5883 title: "Active task",
5884 body: "",
5885 author: "user",
5886 url: "https://github.com/active/repo/issues/1",
5887 labels: &[],
5888 origin: "github",
5889 })
5890 .await
5891 .unwrap();
5892
5893 let inactive_id = store
5895 .upsert_external(&UpsertExternal {
5896 repo: "inactive/repo",
5897 ext_id: "42",
5898 title: "Orphaned task",
5899 body: "",
5900 author: "user",
5901 url: "https://github.com/inactive/repo/issues/42",
5902 labels: &[],
5903 origin: "github",
5904 })
5905 .await
5906 .unwrap();
5907
5908 assert_eq!(store.get(active_id).await.unwrap().status, TaskStatus::New);
5909 assert_eq!(
5910 store.get(inactive_id).await.unwrap().status,
5911 TaskStatus::New
5912 );
5913
5914 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
5915 let mut tier = HashMap::new();
5916 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5917 model_map.insert("medium".to_string(), tier);
5918 let config = RouterConfig {
5919 mode: "round_robin".to_string(),
5920 agents: vec!["claude".to_string()],
5921 model_map,
5922 ..Default::default()
5923 };
5924 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5925
5926 let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
5927 let mut routing_quota = RoutingTickQuota::new(1);
5928 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5929 .await
5930 .unwrap();
5931
5932 assert_eq!(
5934 store.get(active_id).await.unwrap().status,
5935 TaskStatus::New,
5936 "global sweep must not touch tasks for the active repo"
5937 );
5938
5939 let after = store.get(inactive_id).await.unwrap();
5941 assert_eq!(
5942 after.status,
5943 TaskStatus::Routed,
5944 "global sweep must route tasks from inactive repos"
5945 );
5946 assert!(
5947 matches!(&after.agent, Some(a) if !a.is_empty()),
5948 "agent must be set after routing"
5949 );
5950 }
5951
5952 #[serial_test::serial(cooldown_state)]
5960 #[tokio::test]
5961 async fn global_sweep_does_not_touch_other_active_repo_tasks() {
5962 cooldown::reset_global_state().await;
5963
5964 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5965
5966 let other_active_id = store
5969 .upsert_external(&UpsertExternal {
5970 repo: "other-active/repo",
5971 ext_id: "7",
5972 title: "Other active project's task",
5973 body: "",
5974 author: "user",
5975 url: "https://github.com/other-active/repo/issues/7",
5976 labels: &[],
5977 origin: "github",
5978 })
5979 .await
5980 .unwrap();
5981
5982 let inactive_id = store
5984 .upsert_external(&UpsertExternal {
5985 repo: "inactive/repo",
5986 ext_id: "42",
5987 title: "Orphaned task",
5988 body: "",
5989 author: "user",
5990 url: "https://github.com/inactive/repo/issues/42",
5991 labels: &[],
5992 origin: "github",
5993 })
5994 .await
5995 .unwrap();
5996
5997 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
5998 let mut tier = HashMap::new();
5999 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
6000 model_map.insert("medium".to_string(), tier);
6001 let config = RouterConfig {
6002 mode: "round_robin".to_string(),
6003 agents: vec!["claude".to_string()],
6004 model_map,
6005 ..Default::default()
6006 };
6007 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
6008
6009 let active_repos: HashSet<String> =
6013 ["active/repo".to_string(), "other-active/repo".to_string()]
6014 .into_iter()
6015 .collect();
6016 let mut routing_quota = RoutingTickQuota::new(1);
6017 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
6018 .await
6019 .unwrap();
6020
6021 assert_eq!(
6022 store.get(other_active_id).await.unwrap().status,
6023 TaskStatus::New,
6024 "global sweep run from a different active repo's tick must not route \
6025 another active repo's new tasks — that repo's own tick will route them"
6026 );
6027 assert_eq!(
6028 store.get(inactive_id).await.unwrap().status,
6029 TaskStatus::Routed,
6030 "global sweep must still route tasks from genuinely orphaned repos"
6031 );
6032 }
6033
6034 #[serial_test::serial(cooldown_state)]
6036 #[tokio::test]
6037 async fn global_sweep_skips_no_agent_tasks() {
6038 cooldown::reset_global_state().await;
6039
6040 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6041
6042 let id = store
6043 .upsert_external(&UpsertExternal {
6044 repo: "inactive/repo",
6045 ext_id: "99",
6046 title: "No-agent task",
6047 body: "",
6048 author: "user",
6049 url: "https://github.com/inactive/repo/issues/99",
6050 labels: &["no-agent".to_string()],
6051 origin: "github",
6052 })
6053 .await
6054 .unwrap();
6055
6056 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
6057 let mut tier = HashMap::new();
6058 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
6059 model_map.insert("medium".to_string(), tier);
6060 let config = RouterConfig {
6061 mode: "round_robin".to_string(),
6062 agents: vec!["claude".to_string()],
6063 model_map,
6064 ..Default::default()
6065 };
6066 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
6067
6068 let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
6069 let mut routing_quota = RoutingTickQuota::new(1);
6070 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
6071 .await
6072 .unwrap();
6073
6074 assert_eq!(
6075 store.get(id).await.unwrap().status,
6076 TaskStatus::New,
6077 "no-agent tasks must not be routed"
6078 );
6079 }
6080
6081 #[serial_test::serial(cooldown_state)]
6086 #[tokio::test]
6087 async fn global_sweep_honors_shared_routing_quota() {
6088 cooldown::reset_global_state().await;
6089
6090 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6091 for ext_id in ["1", "2"] {
6092 store
6093 .upsert_external(&UpsertExternal {
6094 repo: "inactive/repo",
6095 ext_id,
6096 title: "Orphaned task",
6097 body: "",
6098 author: "user",
6099 url: &format!("https://github.com/inactive/repo/issues/{ext_id}"),
6100 labels: &[],
6101 origin: "github",
6102 })
6103 .await
6104 .unwrap();
6105 }
6106
6107 let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
6108 let mut tier = HashMap::new();
6109 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
6110 model_map.insert("medium".to_string(), tier);
6111 let config = RouterConfig {
6112 mode: "round_robin".to_string(),
6113 agents: vec!["claude".to_string()],
6114 model_map,
6115 ..Default::default()
6116 };
6117 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
6118
6119 let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
6120 let mut routing_quota = RoutingTickQuota::new(1);
6121 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
6122 .await
6123 .unwrap();
6124
6125 let all_tasks = store.list_all_global().await.unwrap();
6126 let routed = all_tasks
6127 .iter()
6128 .filter(|task| task.status == TaskStatus::Routed)
6129 .count();
6130 let still_new = all_tasks
6131 .iter()
6132 .filter(|task| task.status == TaskStatus::New)
6133 .count();
6134
6135 assert_eq!(routed, 1, "only one task may consume the shared quota");
6136 assert_eq!(still_new, 1, "remaining tasks wait for the next tick");
6137
6138 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
6139 .await
6140 .unwrap();
6141 let after_exhausted = store.list_all_global().await.unwrap();
6142 assert_eq!(
6143 after_exhausted
6144 .iter()
6145 .filter(|task| task.status == TaskStatus::Routed)
6146 .count(),
6147 1,
6148 "an exhausted shared quota must not reset within the same tick"
6149 );
6150 }
6151
6152 #[tokio::test]
6169 async fn group_orphaned_routed_tasks_selects_inactive_repo_tasks() {
6170 async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
6171 let id = store
6172 .upsert_external(&UpsertExternal {
6173 repo,
6174 ext_id,
6175 title: "Routed task",
6176 body: "",
6177 author: "user",
6178 url: &format!("https://github.com/{repo}/issues/{ext_id}"),
6179 labels: &[],
6180 origin: "github",
6181 })
6182 .await
6183 .unwrap();
6184 store
6185 .store_route(&StoreRoute {
6186 id,
6187 agent: "claude",
6188 model: Some("sonnet"),
6189 complexity: "medium",
6190 estimate: 0,
6191 reason: "test",
6192 profile: "{}",
6193 skills: "[]",
6194 })
6195 .await
6196 .unwrap();
6197 store.update_status(id, TaskStatus::Routed).await.unwrap();
6198 id
6199 }
6200
6201 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6202
6203 let active_id = make_routed(&store, "active/repo", "1").await;
6204 let inactive_id_a = make_routed(&store, "inactive/repo", "2").await;
6205 let other_inactive_id = make_routed(&store, "other-inactive/repo", "3").await;
6206 let inactive_id_b = make_routed(&store, "inactive/repo", "4").await;
6207
6208 let all_routed = store
6209 .list_all_by_status_global(TaskStatus::Routed)
6210 .await
6211 .unwrap();
6212 let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
6213 let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
6214
6215 assert!(
6216 !by_repo.contains_key("active/repo"),
6217 "global sweep must not select tasks for the active repo"
6218 );
6219 let inactive_ids: HashSet<i64> = by_repo
6220 .get("inactive/repo")
6221 .expect("inactive/repo must be selected")
6222 .iter()
6223 .map(|t| t.id)
6224 .collect();
6225 assert_eq!(
6226 inactive_ids,
6227 [inactive_id_a, inactive_id_b].into_iter().collect(),
6228 "global sweep must group all orphaned tasks for a repo together"
6229 );
6230 assert_eq!(
6231 by_repo
6232 .get("other-inactive/repo")
6233 .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
6234 Some(vec![other_inactive_id])
6235 );
6236
6237 assert_eq!(
6240 store.get(active_id).await.unwrap().status,
6241 TaskStatus::Routed
6242 );
6243 }
6244
6245 #[tokio::test]
6250 async fn group_orphaned_routed_tasks_excludes_all_active_repos_not_just_current() {
6251 async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
6252 let id = store
6253 .upsert_external(&UpsertExternal {
6254 repo,
6255 ext_id,
6256 title: "Routed task",
6257 body: "",
6258 author: "user",
6259 url: &format!("https://github.com/{repo}/issues/{ext_id}"),
6260 labels: &[],
6261 origin: "github",
6262 })
6263 .await
6264 .unwrap();
6265 store
6266 .store_route(&StoreRoute {
6267 id,
6268 agent: "claude",
6269 model: Some("sonnet"),
6270 complexity: "medium",
6271 estimate: 0,
6272 reason: "test",
6273 profile: "{}",
6274 skills: "[]",
6275 })
6276 .await
6277 .unwrap();
6278 store.update_status(id, TaskStatus::Routed).await.unwrap();
6279 id
6280 }
6281
6282 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6283
6284 let repo_a_id = make_routed(&store, "active/repo-a", "1").await;
6285 let repo_b_id = make_routed(&store, "active/repo-b", "2").await;
6286 let orphaned_id = make_routed(&store, "inactive/repo", "3").await;
6287
6288 let all_routed = store
6289 .list_all_by_status_global(TaskStatus::Routed)
6290 .await
6291 .unwrap();
6292 let active_repos: HashSet<String> =
6295 ["active/repo-a".to_string(), "active/repo-b".to_string()]
6296 .into_iter()
6297 .collect();
6298 let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
6299
6300 assert!(
6301 !by_repo.contains_key("active/repo-a"),
6302 "the current repo's own tasks must not be swept"
6303 );
6304 assert!(
6305 !by_repo.contains_key("active/repo-b"),
6306 "another active repo's tasks must not be swept during repo A's tick"
6307 );
6308 assert_eq!(
6309 by_repo
6310 .get("inactive/repo")
6311 .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
6312 Some(vec![orphaned_id]),
6313 "a genuinely inactive repo's tasks must still be swept"
6314 );
6315
6316 assert_eq!(
6318 store.get(repo_a_id).await.unwrap().status,
6319 TaskStatus::Routed
6320 );
6321 assert_eq!(
6322 store.get(repo_b_id).await.unwrap().status,
6323 TaskStatus::Routed
6324 );
6325 }
6326
6327 #[tokio::test]
6329 async fn global_dispatch_sweep_skips_no_agent_tasks() {
6330 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6331
6332 let id = store
6333 .upsert_external(&UpsertExternal {
6334 repo: "inactive/repo",
6335 ext_id: "99",
6336 title: "No-agent routed task",
6337 body: "",
6338 author: "user",
6339 url: "https://github.com/inactive/repo/issues/99",
6340 labels: &["no-agent".to_string()],
6341 origin: "github",
6342 })
6343 .await
6344 .unwrap();
6345 store
6346 .store_route(&StoreRoute {
6347 id,
6348 agent: "claude",
6349 model: Some("sonnet"),
6350 complexity: "medium",
6351 estimate: 0,
6352 reason: "test",
6353 profile: "{}",
6354 skills: "[]",
6355 })
6356 .await
6357 .unwrap();
6358 store.update_status(id, TaskStatus::Routed).await.unwrap();
6359
6360 let tmux = Arc::new(TmuxManager::new());
6365 let transport = Arc::new(Transport::new());
6366 let capture = Arc::new(CaptureService::new(transport));
6367 let semaphore = Arc::new(Semaphore::new(4));
6368 let (weight_tx, _weight_rx) = mpsc::channel(16);
6369 let dispatching = Arc::new(DashMap::new());
6370 let dispatch_mode = DispatchMode {
6371 is_degraded: false,
6372 healthy_agents: 1,
6373 threshold: 1,
6374 };
6375
6376 let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
6377 dispatch_routed_tasks_global(
6378 &tmux,
6379 &capture,
6380 &semaphore,
6381 &weight_tx,
6382 dispatch_mode,
6383 &dispatching,
6384 &store,
6385 &HashMap::new(),
6386 &active_repos,
6387 )
6388 .await
6389 .unwrap();
6390
6391 assert_eq!(
6392 store.get(id).await.unwrap().status,
6393 TaskStatus::Routed,
6394 "no-agent tasks must not be dispatched"
6395 );
6396 }
6397}