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