1use crate::backends::{ExternalBackend, ExternalId, ExternalTask, Status};
17use crate::channels::capture::CaptureService;
18use crate::config;
19use crate::engine::cooldown::{
20 github_circuit_remaining_secs, is_agent_in_cooldown, is_github_circuit_open,
21 is_model_in_cooldown, record_agent_failure_with_message, record_silence_detection,
22 set_agent_cooldown, set_model_cooldown, SILENCE_AGENT_COOLDOWN_SECS,
23 SILENCE_EXTENDED_COOLDOWN_SECS,
24};
25use crate::engine::dispatch_guard::DispatchGuard;
26use crate::engine::jobs;
27use crate::engine::router::{get_route_result, AllAgentsCooledError, RouteResultError, Router};
28use crate::engine::runner::{TaskRunner, WeightSignal};
29use crate::engine::tasks::{is_internal_id, TaskManager};
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::{mpsc, Semaphore};
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::{set_review_session_expected, 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
284#[allow(clippy::too_many_arguments)]
285fn stuck_task_timing_from_map(
286 tmux: &Arc<TmuxManager>,
287 repo: &str,
288 session_task_id: &str,
289 updated_at: &str,
290 config: &EngineConfig,
291 parse_error_message: &'static str,
292 session_map: &std::collections::HashMap<String, bool>,
293) -> Option<StuckTaskTiming> {
294 let session_name = tmux.session_name(repo, session_task_id);
295 let has_session = session_map.get(&session_name).copied().unwrap_or(false);
297 let threshold = if has_session {
298 config.stuck_timeout
299 } else {
300 config.no_session_stuck_timeout
301 };
302
303 let updated = match chrono::DateTime::parse_from_rfc3339(updated_at) {
304 Ok(dt) => dt.with_timezone(&chrono::Utc),
305 Err(e) => {
306 tracing::warn!(
307 task_id = session_task_id,
308 updated_at,
309 ?e,
310 error_message = parse_error_message,
311 "cannot parse updated_at"
312 );
313 return None;
314 }
315 };
316
317 let raw_age = chrono::Utc::now() - updated;
318 let suspended = super::suspend::suspended_duration_since(updated);
322 let age = (raw_age - suspended).max(chrono::Duration::zero());
323 if age.num_seconds() <= threshold as i64 {
324 return None;
325 }
326
327 Some(StuckTaskTiming {
328 has_session,
329 age,
330 threshold,
331 })
332}
333
334async fn in_review_stuck_timestamp(store: &TaskStore, store_id: i64, fallback: &str) -> String {
336 match store.in_review_entry(store_id).await {
337 Ok(Some((entered_at, _))) => entered_at,
338 Ok(None) => fallback.to_string(),
339 Err(e) => {
340 tracing::warn!(
341 store_id,
342 error = %e,
343 "failed to read in_review entry, falling back to updated_at"
344 );
345 fallback.to_string()
346 }
347 }
348}
349
350async fn finalize_open_runs_for_recovery(store: &TaskStore, store_id: i64, reason: &str) {
353 if let Err(e) = store
354 .finalize_incomplete_runs(store_id, "aborted", reason)
355 .await
356 {
357 tracing::warn!(
358 store_id,
359 error = %e,
360 reason,
361 "failed to finalize incomplete runs during recovery"
362 );
363 }
364}
365
366pub(crate) async fn tick_detect_silent_agents(
375 tmux: &Arc<TmuxManager>,
376 repo: &str,
377 capture: &Arc<CaptureService>,
378 backend: &Arc<dyn ExternalBackend>,
379 task_manager: &Arc<TaskManager>,
380 config: &EngineConfig,
381 store: &Arc<TaskStore>,
382) -> anyhow::Result<()> {
383 let _span = tracing::info_span!("engine.tick.phase1b.silence").entered();
384 let grace = std::time::Duration::from_secs(config.silence_grace_period);
385 let silent_sessions = capture.get_silent_sessions_for_repo(repo, grace).await;
386
387 for (task_id, session_name, silence_age_secs) in silent_sessions {
388 let use_backend = should_use_backend(&task_id);
389 let store_task = match store.resolve_task_id(repo, &task_id).await {
391 Ok(Some(store_id)) => match store.get(store_id).await {
392 Ok(task) => Some(task),
393 Err(e) => {
394 tracing::warn!(
395 task_id,
396 repo,
397 store_id,
398 operation = "store.get",
399 error = %e,
400 "silence detection: db error fetching task — agent/model cooldown will be skipped"
401 );
402 None
403 }
404 },
405 Ok(None) => {
406 tracing::debug!(
407 task_id,
408 repo,
409 operation = "resolve_task_id",
410 "silence detection: task not found in store (may have been cleaned up)"
411 );
412 None
413 }
414 Err(e) => {
415 tracing::warn!(
416 task_id,
417 repo,
418 operation = "resolve_task_id",
419 error = %e,
420 "silence detection: db error resolving task id — agent/model cooldown will be skipped"
421 );
422 None
423 }
424 };
425 let agent_name = store_task
426 .as_ref()
427 .and_then(|t| t.agent.clone())
428 .unwrap_or_default();
429 let model_name = store_task
430 .as_ref()
431 .and_then(|t| t.model.clone())
432 .unwrap_or_default();
433
434 tracing::warn!(
435 task_id,
436 agent = %agent_name,
437 model = %model_name,
438 silence_age_secs,
439 grace_secs = config.silence_grace_period,
440 cooldown_secs = config.silence_cooldown,
441 "agent silent since session start — killing session, cooling down model + agent, failing over"
442 );
443
444 if let Err(e) = tmux.kill_session(&session_name).await {
446 tracing::debug!(
447 task_id,
448 ?e,
449 "kill_session failed for silent agent (may already be gone)"
450 );
451 }
452
453 capture.unregister_session(repo, &task_id).await;
455
456 if let Some(store_id) = store_task.as_ref().map(|t| t.id) {
458 finalize_open_runs_for_recovery(store, store_id, "silence detection: session killed")
459 .await;
460 }
461
462 let mut extended_note = String::new();
463
464 if !agent_name.is_empty() && !model_name.is_empty() {
466 set_model_cooldown(
467 &agent_name,
468 &model_name,
469 config.silence_cooldown,
470 "silence_detected",
471 )
472 .await;
473 if let Some(result) = record_silence_detection(&agent_name, &model_name).await {
474 if result.extended_cooldown_applied {
475 extended_note = format!(
476 " ({} silences in 24h -> extended cooldown {}s)",
477 result.count, SILENCE_EXTENDED_COOLDOWN_SECS
478 );
479 }
480 }
481 }
482
483 if !agent_name.is_empty() {
487 set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
488 }
489
490 let task_eid = ExternalId(task_id.clone());
495
496 let available: Vec<String> = crate::engine::configured_agents()
498 .into_iter()
499 .filter(|a| crate::cmd_cache::command_exists(a))
500 .collect();
501 let chain = crate::engine::runner::response::get_reroute_chain(
502 &task_id,
503 &Some(Arc::clone(store)),
504 repo,
505 )
506 .await;
507 let chain = crate::engine::runner::response::update_reroute_chain(
508 &task_id,
509 &agent_name,
510 &chain,
511 &Some(Arc::clone(store)),
512 repo,
513 )
514 .await;
515
516 let (next_status, next_agent) = if let Some(fallback) =
517 crate::engine::runner::response::pick_fallback_agent(&agent_name, &chain, &available)
518 {
519 tracing::info!(
520 task_id,
521 from = %agent_name,
522 to = %fallback,
523 "silence detection: failover to different agent, setting routed"
524 );
525 (Status::Routed, Some(fallback))
526 } else {
527 tracing::warn!(
528 task_id,
529 "silence detection: no fallback agents available, resetting to new for re-routing"
530 );
531 (Status::New, None)
532 };
533
534 if use_backend {
535 if let Some(ref st) = store_task {
536 let stale_labels: Vec<String> = st
538 .labels
539 .iter()
540 .filter(|l| {
541 l.starts_with("agent:")
542 || l.starts_with("complexity:")
543 || l.starts_with("model:")
544 })
545 .cloned()
546 .collect();
547 if !stale_labels.is_empty() {
548 let backend_clone = Arc::clone(backend);
549 let task_eid_clone = task_eid.clone();
550 tokio::spawn(async move {
551 for label in &stale_labels {
552 if let Err(e) = backend_clone.remove_label(&task_eid_clone, label).await
553 {
554 tracing::warn!(task_id = task_eid_clone.0, label, error = %e, "failed to remove label during silence detection re-route");
555 }
556 }
557 });
558 }
559 }
560 }
561
562 if let Some(store_id) = store_task.as_ref().map(|t| t.id) {
563 if let Some(ref fallback) = next_agent {
564 let _ = store_set_by_id(
568 &Some(store),
569 store_id,
570 &[
571 ("agent", serde_json::json!(fallback)),
572 ("model", serde_json::json!("")),
573 (
574 "last_error",
575 serde_json::json!(format!(
576 "silence detected after {}s, failing over to {}",
577 config.silence_grace_period, fallback
578 )),
579 ),
580 ],
581 )
582 .await;
583 } else {
584 let _ = store_set_by_id(
585 &Some(store),
586 store_id,
587 &[
588 ("agent", serde_json::Value::Null),
589 ("model", serde_json::Value::Null),
590 (
591 "last_error",
592 serde_json::json!(format!(
593 "silence detected after {}s, no fallback agents available",
594 config.silence_grace_period
595 )),
596 ),
597 ],
598 )
599 .await;
600 }
601 }
602
603 if let Err(e) = task_manager
604 .update_task_status(&task_eid, next_status)
605 .await
606 {
607 tracing::warn!(task_id, ?e, "failed to update silent task status");
608 continue;
609 }
610
611 let details = serde_json::json!({
613 "failure_reason": "agent_silence",
614 "silence_duration_secs": config.silence_grace_period,
615 "cooldown_applied": !agent_name.is_empty() && !model_name.is_empty(),
616 "fallback_available": next_agent.is_some(),
617 });
618 crate::store::store_log_activity(
619 &Some(Arc::clone(store)),
620 repo,
621 &task_id,
622 "rerouted",
623 Some("in_progress"),
624 Some(next_status.as_str()),
625 next_agent.as_deref(),
626 None,
627 Some(&details),
628 )
629 .await;
630
631 let action = if let Some(ref fallback) = next_agent {
633 format!("failing over to {fallback}")
634 } else {
635 "marking needs_review (no fallback agents)".to_string()
636 };
637 let comment = format!(
638 "[{}] agent silent for {}s since session start — killed session, cooled down model `{}:{}` for {}s{}, agent `{}` for {}s, {}{}",
639 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
640 config.silence_grace_period,
641 agent_name,
642 model_name,
643 config.silence_cooldown,
644 extended_note,
645 agent_name,
646 SILENCE_AGENT_COOLDOWN_SECS,
647 action,
648 crate::engine::orch_footer(),
649 );
650 if use_backend {
651 let backend_clone = Arc::clone(backend);
653 let task_eid_clone = task_eid.clone();
654 tokio::spawn(async move {
655 if let Err(e) = backend_clone.post_comment(&task_eid_clone, &comment).await {
656 tracing::warn!(
657 task_id = task_eid_clone.0,
658 ?e,
659 "failed to post silence detection comment"
660 );
661 }
662 });
663 }
664 }
665
666 Ok(())
667}
668
669fn should_use_backend(task_id: &str) -> bool {
670 !is_internal_id(task_id)
671}
672
673#[allow(clippy::too_many_arguments)]
678pub(crate) async fn tick_recover_stuck_tasks(
679 backend: &Arc<dyn ExternalBackend>,
680 tmux: &Arc<TmuxManager>,
681 repo: &str,
682 task_manager: &Arc<TaskManager>,
683 config: &EngineConfig,
684 store: &Arc<TaskStore>,
685 session_map: &std::collections::HashMap<String, bool>,
686 dispatching: &Arc<DashMap<String, String>>,
687) -> anyhow::Result<()> {
688 let _span = tracing::info_span!("engine.tick.phase2.stuck_tasks").entered();
689
690 let in_progress = match task_manager
691 .list_external_by_status(Status::InProgress)
692 .await
693 {
694 Ok(tasks) => tasks,
695 Err(e) => {
696 tracing::warn!(?e, "failed to list in_progress tasks for stuck recovery");
697 vec![]
698 }
699 };
700 for task in &in_progress {
701 let Some(timing) = stuck_task_timing_from_map(
702 tmux,
703 repo,
704 &task.id.0,
705 &task.updated_at,
706 config,
707 "cannot parse updated_at, skipping stuck-task check",
708 session_map,
709 ) else {
710 continue;
711 };
712
713 if !timing.has_session {
720 let dispatch_key = format!("{repo}/{}", task.id.0);
721 if dispatching.contains_key(&dispatch_key) {
722 tracing::info!(
730 task_id = task.id.0,
731 dispatch_key,
732 age_mins = timing.age.num_minutes(),
733 "skipping stuck-task reclaim: runner still actively processing this task"
734 );
735 continue;
736 }
737 }
738
739 if timing.has_session {
740 tracing::warn!(
741 task_id = task.id.0,
742 age_mins = timing.age.num_minutes(),
743 threshold_mins = timing.threshold / 60,
744 "recovering stuck task: timed out with active session → new"
745 );
746 } else {
747 tracing::warn!(
748 task_id = task.id.0,
749 dispatch_key = format!("{repo}/{}", task.id.0),
750 age_mins = timing.age.num_minutes(),
751 threshold_mins = timing.threshold / 60,
752 "recovering stuck task: no session found — reclaiming early → new"
753 );
754 }
755 let mut cached_store_id: Option<i64> = None;
760 if timing.has_session {
761 let store_task = match store.resolve_task_id(repo, &task.id.0).await {
762 Ok(Some(store_id)) => {
763 cached_store_id = Some(store_id);
764 match store.get(store_id).await {
765 Ok(t) => Some(t),
766 Err(e) => {
767 tracing::warn!(task_id = task.id.0, error = %e, "failed to fetch task from store for stuck-task cooldown");
768 None
769 }
770 }
771 }
772 Ok(None) => None,
773 Err(e) => {
774 tracing::warn!(task_id = task.id.0, error = %e, "failed to resolve task id for stuck-task cooldown");
775 None
776 }
777 };
778 let agent_name = store_task
779 .as_ref()
780 .and_then(|t| t.agent.clone())
781 .unwrap_or_default();
782 let model_name = store_task
783 .as_ref()
784 .and_then(|t| t.model.clone())
785 .unwrap_or_default();
786
787 if !agent_name.is_empty() && !model_name.is_empty() {
788 set_model_cooldown(
789 &agent_name,
790 &model_name,
791 config.silence_cooldown,
792 "silence_detected",
793 )
794 .await;
795 record_agent_failure_with_message(
796 &agent_name,
797 &format!(
798 "stuck with active session after {}m",
799 timing.age.num_minutes()
800 ),
801 )
802 .await;
803 }
804 if !agent_name.is_empty() {
805 set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
806 }
807 tracing::warn!(
808 task_id = task.id.0,
809 agent = %agent_name,
810 model = %model_name,
811 cooldown_secs = config.silence_cooldown,
812 "stuck-task cooldown applied — router will avoid this agent/model on next attempt"
813 );
814 }
815
816 {
819 let stale_labels: Vec<String> = task
820 .labels
821 .iter()
822 .filter(|l| l.starts_with("agent:") || l.starts_with("model:"))
823 .cloned()
824 .collect();
825 if !stale_labels.is_empty() {
826 let backend_clone = Arc::clone(backend);
827 let task_id_clone = task.id.clone();
828 tokio::spawn(async move {
829 for label in &stale_labels {
830 if let Err(e) = backend_clone.remove_label(&task_id_clone, label).await {
831 tracing::warn!(task_id = task_id_clone.0, label, error = %e, "failed to remove stale routing label during stuck-task recovery");
832 }
833 }
834 });
835 }
836 }
837 let resolved_store_id = match cached_store_id {
838 Some(id) => Some(id),
839 None => match store.resolve_task_id(repo, &task.id.0).await {
840 Ok(Some(id)) => Some(id),
841 Ok(None) => {
842 tracing::warn!(
843 task_id = %task.id.0,
844 repo,
845 "resolve_task_id returned None during stuck-task recovery"
846 );
847 None
848 }
849 Err(e) => {
850 tracing::error!(
851 task_id = %task.id.0,
852 repo,
853 error = %e,
854 "resolve_task_id failed during stuck-task recovery"
855 );
856 None
857 }
858 },
859 };
860 if let Some(store_id) = resolved_store_id {
861 if let Err(e) = store_set_by_id(
862 &Some(store),
863 store_id,
864 &[
865 ("agent", serde_json::Value::Null),
866 ("model", serde_json::Value::Null),
867 ("route_attempts", serde_json::json!(0)),
868 ],
869 )
870 .await
871 {
872 tracing::warn!(
873 task_id = task.id.0,
874 ?e,
875 "failed to clear routing fields for stuck task — will retry next tick"
876 );
877 continue;
878 }
879 finalize_open_runs_for_recovery(
880 store,
881 store_id,
882 if timing.has_session {
883 "stuck-task recovery: active session killed"
884 } else {
885 "stuck-task recovery: no session found"
886 },
887 )
888 .await;
889 }
890 if let Err(e) = task_manager.update_task_status(&task.id, Status::New).await {
891 tracing::warn!(task_id = task.id.0, ?e, "failed to reset stuck task status");
892 continue;
893 }
894
895 let details = serde_json::json!({
897 "failure_reason": if timing.has_session { "stuck_with_session" } else { "stuck_no_session" },
898 "age_minutes": timing.age.num_minutes(),
899 "had_session": timing.has_session,
900 });
901 crate::store::store_log_activity(
902 &Some(Arc::clone(store)),
903 repo,
904 &task.id.0,
905 "rerouted",
906 Some("in_progress"),
907 Some("new"),
908 None::<&str>,
909 None::<&str>,
910 Some(&details),
911 )
912 .await;
913
914 let reason = if timing.has_session {
915 format!(
916 "timed out after {}m with active session (cleared agent for re-routing)",
917 timing.age.num_minutes()
918 )
919 } else {
920 format!(
921 "no session found — reclaiming early after {}m (cleared agent for re-routing)",
922 timing.age.num_minutes()
923 )
924 };
925 {
927 let backend_clone = Arc::clone(backend);
928 let task_id_clone = task.id.clone();
929 let body = format!(
930 "[{}] recovered: stuck in_progress — {}{}",
931 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
932 reason,
933 crate::engine::orch_footer()
934 );
935 tokio::spawn(async move {
936 if let Err(e) = backend_clone.post_comment(&task_id_clone, &body).await {
937 tracing::warn!(
938 task_id = task_id_clone.0,
939 ?e,
940 "failed to post stuck-task recovery comment"
941 );
942 }
943 });
944 }
945 }
946
947 let mut routed = match task_manager.list_external_by_status(Status::Routed).await {
948 Ok(tasks) => tasks,
949 Err(e) => {
950 tracing::warn!(?e, "failed to list routed tasks for stuck recovery");
951 vec![]
952 }
953 };
954 match task_manager
955 .list_internal_by_status(crate::store::TaskStatus::Routed)
956 .await
957 {
958 Ok(tasks) => routed.extend(tasks),
959 Err(e) => tracing::warn!(
960 ?e,
961 "failed to list internal routed tasks for stuck recovery"
962 ),
963 }
964 for task in &routed {
965 let Some(timing) = stuck_task_timing_from_map(
966 tmux,
967 repo,
968 &task.id.0,
969 &task.updated_at,
970 config,
971 "cannot parse updated_at, skipping routed stuck-task check",
972 session_map,
973 ) else {
974 continue;
975 };
976
977 if !timing.has_session {
978 continue;
979 }
980
981 let session_name = tmux.session_name(repo, &task.id.0);
982 if let Err(e) = tmux.kill_session(&session_name).await {
983 tracing::warn!(
984 task_id = task.id.0,
985 session = %session_name,
986 error = %e,
987 "failed to kill stale tmux session blocking routed task"
988 );
989 continue;
990 }
991
992 recover_routed_blocked_dispatch(backend, store, task_manager, repo, task, &timing).await;
993 }
994
995 use crate::store::TaskStatus as DbStatus;
998 let internal_in_progress = match task_manager
999 .list_internal_by_status(DbStatus::InProgress)
1000 .await
1001 {
1002 Ok(tasks) => tasks,
1003 Err(e) => {
1004 tracing::warn!(
1005 ?e,
1006 "failed to list internal in_progress tasks for stuck recovery"
1007 );
1008 vec![]
1009 }
1010 };
1011 for task in &internal_in_progress {
1012 let task_id = task.id.0.clone();
1013 let Some(timing) = stuck_task_timing_from_map(
1014 tmux,
1015 repo,
1016 &task_id,
1017 &task.updated_at,
1018 config,
1019 "cannot parse updated_at, skipping stuck internal-task check",
1020 session_map,
1021 ) else {
1022 continue;
1023 };
1024
1025 if !timing.has_session {
1031 let dispatch_key = format!("{repo}/{task_id}");
1032 if dispatching.contains_key(&dispatch_key) {
1033 tracing::info!(
1043 task_id,
1044 dispatch_key,
1045 age_mins = timing.age.num_minutes(),
1046 "skipping stuck-task reclaim: runner still actively processing this task"
1047 );
1048 continue;
1049 }
1050 }
1051
1052 if timing.has_session {
1053 tracing::warn!(
1054 task_id,
1055 age_mins = timing.age.num_minutes(),
1056 threshold_mins = timing.threshold / 60,
1057 "recovering stuck task: timed out with active session → new"
1058 );
1059 } else {
1060 tracing::warn!(
1061 task_id,
1062 dispatch_key = format!("{repo}/{task_id}"),
1063 age_mins = timing.age.num_minutes(),
1064 threshold_mins = timing.threshold / 60,
1065 "recovering stuck task: no session found — reclaiming early → new"
1066 );
1067 }
1068 let mut cached_store_id: Option<i64> = None;
1072 if timing.has_session {
1073 let store_task = match store.resolve_task_id(repo, &task_id).await {
1074 Ok(Some(store_id)) => {
1075 cached_store_id = Some(store_id);
1076 match store.get(store_id).await {
1077 Ok(t) => Some(t),
1078 Err(e) => {
1079 tracing::warn!(task_id, error = %e, "failed to fetch task from store for stuck internal-task cooldown");
1080 None
1081 }
1082 }
1083 }
1084 Ok(None) => None,
1085 Err(e) => {
1086 tracing::warn!(task_id, error = %e, "failed to resolve task id for stuck internal-task cooldown");
1087 None
1088 }
1089 };
1090 let agent_name = store_task
1091 .as_ref()
1092 .and_then(|t| t.agent.clone())
1093 .unwrap_or_default();
1094 let model_name = store_task
1095 .as_ref()
1096 .and_then(|t| t.model.clone())
1097 .unwrap_or_default();
1098
1099 if !agent_name.is_empty() && !model_name.is_empty() {
1100 set_model_cooldown(
1101 &agent_name,
1102 &model_name,
1103 config.silence_cooldown,
1104 "silence_detected",
1105 )
1106 .await;
1107 record_agent_failure_with_message(
1108 &agent_name,
1109 &format!(
1110 "internal task stuck with active session after {}m",
1111 timing.age.num_minutes()
1112 ),
1113 )
1114 .await;
1115 }
1116 if !agent_name.is_empty() {
1117 set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
1118 }
1119 tracing::warn!(
1120 task_id,
1121 agent = %agent_name,
1122 model = %model_name,
1123 cooldown_secs = config.silence_cooldown,
1124 "internal stuck-task cooldown applied — router will avoid this agent/model on next attempt"
1125 );
1126 }
1127 let resolved_store_id = match cached_store_id {
1130 Some(id) => Some(id),
1131 None => match store.resolve_task_id(repo, &task_id).await {
1132 Ok(Some(id)) => Some(id),
1133 Ok(None) => {
1134 tracing::warn!(
1135 task_id = %task_id,
1136 repo,
1137 "resolve_task_id returned None during stuck internal-task recovery"
1138 );
1139 None
1140 }
1141 Err(e) => {
1142 tracing::error!(
1143 task_id = %task_id,
1144 repo,
1145 error = %e,
1146 "resolve_task_id failed during stuck internal-task recovery"
1147 );
1148 None
1149 }
1150 },
1151 };
1152 if let Some(store_id) = resolved_store_id {
1153 if let Err(e) = store_set_by_id(
1154 &Some(store),
1155 store_id,
1156 &[
1157 ("agent", serde_json::Value::Null),
1158 ("model", serde_json::Value::Null),
1159 ("route_attempts", serde_json::json!(0)),
1160 ],
1161 )
1162 .await
1163 {
1164 tracing::warn!(
1165 task_id,
1166 ?e,
1167 "failed to clear routing fields for stuck internal task — will retry next tick"
1168 );
1169 continue;
1170 }
1171 finalize_open_runs_for_recovery(
1172 store,
1173 store_id,
1174 if timing.has_session {
1175 "stuck-task recovery: internal active session killed"
1176 } else {
1177 "stuck-task recovery: internal no session found"
1178 },
1179 )
1180 .await;
1181 }
1182 if let Err(e) = task_manager
1183 .update_task_status(&ExternalId(task_id.clone()), Status::New)
1184 .await
1185 {
1186 tracing::warn!(task_id, ?e, "failed to reset stuck internal task status");
1187 }
1188 }
1189
1190 let in_review = match task_manager.list_external_by_status(Status::InReview).await {
1194 Ok(tasks) => tasks,
1195 Err(e) => {
1196 tracing::warn!(?e, "failed to list in_review tasks for stuck recovery");
1197 vec![]
1198 }
1199 };
1200 let store_in_review = match store
1204 .list_by_status(repo, crate::store::TaskStatus::InReview)
1205 .await
1206 {
1207 Ok(v) => v,
1208 Err(e) => {
1209 tracing::warn!(
1210 ?e,
1211 "failed to list store in_review tasks for review_session_expected prefetch"
1212 );
1213 vec![]
1214 }
1215 };
1216 let review_expected_ids: std::collections::HashMap<String, i64> = store_in_review
1217 .into_iter()
1218 .filter(|t| t.review_session_expected)
1219 .filter_map(|t| t.external_id.map(|eid| (eid, t.id)))
1220 .collect();
1221 for task in &in_review {
1222 let Some(&store_id) = review_expected_ids.get(&task.id.0) else {
1223 tracing::debug!(
1224 task_id = task.id.0,
1225 "in_review task is waiting on PR review, skipping stuck-session recovery"
1226 );
1227 continue;
1228 };
1229
1230 let review_task_id = format!("{}-review", task.id.0);
1231 let in_review_config = crate::engine::EngineConfig {
1239 no_session_stuck_timeout: config.in_review_no_session_stuck_timeout,
1240 ..config.clone()
1241 };
1242 let age_at = in_review_stuck_timestamp(store, store_id, &task.updated_at).await;
1243 let Some(timing) = stuck_task_timing_from_map(
1244 tmux,
1245 repo,
1246 &review_task_id,
1247 &age_at,
1248 &in_review_config,
1249 "cannot parse in_review timestamp, skipping stuck in_review check",
1250 session_map,
1251 ) else {
1252 continue;
1253 };
1254
1255 tracing::warn!(
1256 task_id = task.id.0,
1257 age_mins = timing.age.num_minutes(),
1258 threshold_mins = timing.threshold / 60,
1259 "recovering stuck in_review task: no session found — resetting to needs_review"
1260 );
1261 if let Err(e) = task_manager
1262 .update_task_status(&task.id, Status::NeedsReview)
1263 .await
1264 {
1265 tracing::warn!(
1266 task_id = task.id.0,
1267 ?e,
1268 "failed to reset stuck in_review task status"
1269 );
1270 continue;
1271 } else {
1272 set_review_session_expected(store, repo, &task.id.0, false).await;
1273 }
1274
1275 if let Ok(Some(store_id)) = store.resolve_task_id(repo, &task.id.0).await {
1277 finalize_open_runs_for_recovery(
1278 store,
1279 store_id,
1280 "stuck-task recovery: in_review session killed",
1281 )
1282 .await;
1283 }
1284
1285 let details = serde_json::json!({
1287 "failure_reason": "stuck_in_review",
1288 "age_minutes": timing.age.num_minutes(),
1289 "had_session": timing.has_session,
1290 });
1291 crate::store::store_log_activity(
1292 &Some(Arc::clone(store)),
1293 repo,
1294 &task.id.0,
1295 "rerouted",
1296 Some("in_review"),
1297 Some("needs_review"),
1298 None::<&str>,
1299 None::<&str>,
1300 Some(&details),
1301 )
1302 .await;
1303 if let Err(e) = backend
1304 .post_comment(
1305 &task.id,
1306 &format!(
1307 "[{}] recovered: stuck in_review — no session found after {}m, resetting to needs_review{}",
1308 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
1309 timing.age.num_minutes(),
1310 crate::engine::orch_footer()
1311 ),
1312 )
1313 .await
1314 {
1315 tracing::warn!(
1316 task_id = task.id.0,
1317 ?e,
1318 "failed to post stuck in_review recovery comment"
1319 );
1320 }
1321 }
1322
1323 let internal_in_review = match task_manager
1325 .list_internal_by_status(DbStatus::InReview)
1326 .await
1327 {
1328 Ok(tasks) => tasks,
1329 Err(e) => {
1330 tracing::warn!(
1331 ?e,
1332 "failed to list internal in_review tasks for stuck recovery"
1333 );
1334 vec![]
1335 }
1336 };
1337 for task in &internal_in_review {
1338 let task_id = task.id.0.clone();
1339 let Some(&store_id) = review_expected_ids.get(&task_id) else {
1340 tracing::debug!(
1341 task_id,
1342 "internal in_review task is waiting on PR review, skipping stuck-session recovery"
1343 );
1344 continue;
1345 };
1346
1347 let review_task_id = format!("{}-review", task_id);
1348 let in_review_config = crate::engine::EngineConfig {
1349 no_session_stuck_timeout: config.in_review_no_session_stuck_timeout,
1350 ..config.clone()
1351 };
1352 let age_at = in_review_stuck_timestamp(store, store_id, &task.updated_at).await;
1353 let Some(timing) = stuck_task_timing_from_map(
1354 tmux,
1355 repo,
1356 &review_task_id,
1357 &age_at,
1358 &in_review_config,
1359 "cannot parse in_review timestamp, skipping stuck internal in_review check",
1360 session_map,
1361 ) else {
1362 continue;
1363 };
1364
1365 tracing::warn!(
1366 task_id,
1367 age_mins = timing.age.num_minutes(),
1368 threshold_mins = timing.threshold / 60,
1369 "recovering stuck internal in_review task: no session found — resetting to needs_review"
1370 );
1371 if let Err(e) = task_manager
1372 .update_task_status(&ExternalId(task_id.clone()), Status::NeedsReview)
1373 .await
1374 {
1375 tracing::warn!(
1376 task_id,
1377 ?e,
1378 "failed to reset stuck internal in_review task status"
1379 );
1380 } else {
1381 set_review_session_expected(store, repo, &task_id, false).await;
1382 }
1383
1384 if let Ok(Some(store_id)) = store.resolve_task_id(repo, &task_id).await {
1386 finalize_open_runs_for_recovery(
1387 store,
1388 store_id,
1389 "stuck-task recovery: internal in_review session killed",
1390 )
1391 .await;
1392 }
1393 }
1394
1395 Ok(())
1396}
1397
1398pub(crate) async fn recover_untracked_repo_stuck_tasks(
1401 tmux: &Arc<TmuxManager>,
1402 config: &EngineConfig,
1403 store: &Arc<TaskStore>,
1404 session_map: &std::collections::HashMap<String, bool>,
1405 dispatching: &Arc<DashMap<String, String>>,
1406 active_repos: &std::collections::HashSet<String>,
1407) {
1408 let all_external_in_progress = match store
1409 .list_all_by_status_global(crate::store::TaskStatus::InProgress)
1410 .await
1411 {
1412 Ok(tasks) => tasks,
1413 Err(e) => {
1414 tracing::warn!(
1415 ?e,
1416 "failed to list global in_progress tasks for cross-repo stuck recovery"
1417 );
1418 vec![]
1419 }
1420 };
1421 for task in all_external_in_progress
1422 .iter()
1423 .filter(|t| t.origin != "internal" && !active_repos.contains(&t.repo))
1424 {
1425 let external_id = task
1426 .external_id
1427 .clone()
1428 .unwrap_or_else(|| task.id.to_string());
1429 let Some(timing) = stuck_task_timing_from_map(
1430 tmux,
1431 &task.repo,
1432 &external_id,
1433 &task.updated_at,
1434 config,
1435 "cannot parse updated_at, skipping cross-repo stuck-task check",
1436 session_map,
1437 ) else {
1438 continue;
1439 };
1440
1441 if !timing.has_session {
1445 let dispatch_key = format!("{}/{external_id}", task.repo);
1446 if dispatching.contains_key(&dispatch_key) {
1447 tracing::info!(
1451 task_id = task.id,
1452 repo = %task.repo,
1453 dispatch_key,
1454 age_mins = timing.age.num_minutes(),
1455 "skipping cross-repo stuck-task reclaim: runner still actively processing this task"
1456 );
1457 continue;
1458 }
1459 }
1460
1461 if timing.has_session {
1462 tracing::warn!(
1463 task_id = task.id,
1464 repo = %task.repo,
1465 age_mins = timing.age.num_minutes(),
1466 threshold_mins = timing.threshold / 60,
1467 "recovering cross-repo stuck task: timed out with active session → new"
1468 );
1469 } else {
1470 tracing::warn!(
1471 task_id = task.id,
1472 repo = %task.repo,
1473 dispatch_key = format!("{}/{external_id}", task.repo),
1474 age_mins = timing.age.num_minutes(),
1475 threshold_mins = timing.threshold / 60,
1476 "recovering cross-repo stuck task: no session found — reclaiming early → new"
1477 );
1478 }
1479
1480 if let Err(e) = store_set_by_id(
1481 &Some(store),
1482 task.id,
1483 &[
1484 ("agent", serde_json::Value::Null),
1485 ("model", serde_json::Value::Null),
1486 ("route_attempts", serde_json::json!(0)),
1487 ],
1488 )
1489 .await
1490 {
1491 tracing::warn!(
1492 task_id = task.id,
1493 repo = %task.repo,
1494 ?e,
1495 "failed to clear routing fields for cross-repo stuck task — will retry next tick"
1496 );
1497 continue;
1498 }
1499
1500 finalize_open_runs_for_recovery(
1501 store,
1502 task.id,
1503 if timing.has_session {
1504 "stuck-task recovery: cross-repo active session killed"
1505 } else {
1506 "stuck-task recovery: cross-repo no session found"
1507 },
1508 )
1509 .await;
1510
1511 if let Err(e) = store
1512 .update_status(task.id, crate::store::TaskStatus::New)
1513 .await
1514 {
1515 tracing::warn!(
1516 task_id = task.id,
1517 repo = %task.repo,
1518 ?e,
1519 "failed to reset cross-repo stuck task status"
1520 );
1521 continue;
1522 }
1523
1524 let details = serde_json::json!({
1525 "failure_reason": if timing.has_session { "stuck_with_session" } else { "stuck_no_session" },
1526 "age_minutes": timing.age.num_minutes(),
1527 "had_session": timing.has_session,
1528 "scope": "cross_repo_global_sweep",
1529 });
1530 crate::store::store_log_activity(
1531 &Some(Arc::clone(store)),
1532 &task.repo,
1533 &external_id,
1534 "rerouted",
1535 Some("in_progress"),
1536 Some("new"),
1537 None::<&str>,
1538 None::<&str>,
1539 Some(&details),
1540 )
1541 .await;
1542 }
1543}
1544
1545pub(crate) async fn tick_global_sweeps(
1554 task_manager: &Arc<TaskManager>,
1555 store: &Arc<TaskStore>,
1556 router: &Router,
1557 dispatching: &Arc<DashMap<String, String>>,
1558 auto_merge_in_flight: &Arc<DashSet<String>>,
1559) {
1560 if let Err(e) = crate::engine::sync::auto_unblock_ci_failure_blocked_tasks_global(
1565 task_manager,
1566 store,
1567 auto_merge_in_flight,
1568 )
1569 .await
1570 {
1571 tracing::warn!(err = %e, "global CI-failure blocked sweep failed");
1572 }
1573
1574 if let Err(e) = crate::engine::sync::refire_and_escalate_stale_needs_review_global(
1577 task_manager,
1578 store,
1579 router,
1580 dispatching,
1581 )
1582 .await
1583 {
1584 tracing::warn!(err = %e, "global NeedsReview refire/escalation sweep failed");
1585 }
1586
1587 if let Err(e) = crate::engine::sync::auto_recover_rebroadcast_blocked_tasks_global(
1590 router,
1591 task_manager,
1592 store,
1593 )
1594 .await
1595 {
1596 tracing::warn!(err = %e, "global rebroadcast-blocked recovery sweep failed");
1597 }
1598}
1599
1600pub(crate) async fn tick_route_tasks(
1602 backend: &Arc<dyn ExternalBackend>,
1603 task_manager: &Arc<TaskManager>,
1604 router: &mut Router,
1605 store: &Arc<TaskStore>,
1606 repo: &str,
1607 routing_quota: &mut RoutingTickQuota,
1608) -> anyhow::Result<()> {
1609 let _span = tracing::info_span!("engine.tick.phase3a.route").entered();
1610 if let Err(err) = crate::engine::dependencies::apply_claim_gates(store).await {
1611 tracing::warn!(err = %err, "dependency claim gate failed");
1612 }
1613 if routing_quota.is_exhausted() {
1614 tracing::debug!("routing quota exhausted for this tick — skipping repo routing phase");
1615 return Ok(());
1616 }
1617
1618 if is_github_circuit_open() {
1622 let remaining = github_circuit_remaining_secs();
1623 tracing::info!(
1624 remaining_secs = remaining,
1625 "GitHub 5xx circuit breaker open — skipping routing phase"
1626 );
1627 return Ok(());
1628 }
1629 let new_tasks = task_manager.list_routable().await?;
1630 let routable: Vec<&ExternalTask> = new_tasks
1631 .iter()
1632 .filter(|t| !skips_agent(&t.labels))
1633 .collect();
1634
1635 if !routable.is_empty() {
1639 let start = std::time::Instant::now();
1640 router.refresh_health(store).await;
1641 tracing::debug!(
1642 duration_ms = start.elapsed().as_millis(),
1643 "router.refresh_health completed"
1644 );
1645 }
1646
1647 for task in routable.into_iter().take(routing_quota.remaining()) {
1649 if !routing_quota.consume_one() {
1650 break;
1651 }
1652 let _task_span = tracing::info_span!("engine.route", task_id = %task.id.0).entered();
1653
1654 let task_start = Instant::now();
1655 match router.route(task, store, repo).await {
1656 Ok(result) => {
1657 tracing::debug!(task_id = %task.id.0, duration_ms = task_start.elapsed().as_millis(), "route completed");
1658 if let Err(e) = router
1660 .store_route_result(&task.id.0, &result, store, repo)
1661 .await
1662 {
1663 tracing::warn!(
1664 task_id = task.id.0,
1665 ?e,
1666 "failed to store route result; skipping Routed transition"
1667 );
1668 continue;
1669 }
1670
1671 if is_internal_id(&task.id.0) {
1672 if let Err(e) = task_manager
1674 .update_task_status(&task.id, Status::Routed)
1675 .await
1676 {
1677 tracing::warn!(
1678 task_id = task.id.0,
1679 ?e,
1680 "failed to set internal status:routed"
1681 );
1682 continue;
1683 }
1684 } else {
1685 let backend_clone = Arc::clone(backend);
1689 let task_id_clone = task.id.clone();
1690 let task_labels: Vec<String> = task.labels.clone();
1691 let new_labels = {
1692 let mut labels = vec![
1693 format!("agent:{}", result.agent),
1694 format!("complexity:{}", result.complexity),
1695 ];
1696 if let Some(ref model) = result.model {
1697 labels.push(format!("model:{model}"));
1698 }
1699 labels
1700 };
1701 tokio::spawn(async move {
1702 for label in &task_labels {
1704 if label.starts_with("agent:")
1705 || label.starts_with("complexity:")
1706 || label.starts_with("model:")
1707 {
1708 if let Err(e) =
1709 backend_clone.remove_label(&task_id_clone, label).await
1710 {
1711 tracing::warn!(task_id = task_id_clone.0, label, error = %e, "failed to remove stale routing label during re-route");
1712 }
1713 }
1714 }
1715 if let Err(e) = backend_clone.set_labels(&task_id_clone, &new_labels).await
1717 {
1718 tracing::warn!(
1719 task_id = task_id_clone.0,
1720 ?e,
1721 "failed to set routing labels"
1722 );
1723 }
1724 });
1725
1726 if let Err(e) = task_manager
1728 .update_task_status(&task.id, Status::Routed)
1729 .await
1730 {
1731 tracing::warn!(task_id = task.id.0, ?e, "failed to set status:routed");
1732 continue;
1733 }
1734 }
1735
1736 match store.ensure_external_task(repo, task).await {
1738 Ok(store_id) => {
1739 let profile_json =
1740 serde_json::to_string(&result.profile).unwrap_or_default();
1741 let skills_json =
1742 serde_json::to_string(&result.selected_skills).unwrap_or_default();
1743 if let Err(e) = store
1744 .store_route(&crate::store::StoreRoute {
1745 id: store_id,
1746 agent: &result.agent,
1747 model: result.model.as_deref(),
1748 complexity: &result.complexity,
1749 estimate: result.estimate,
1750 reason: &result.reason,
1751 profile: &profile_json,
1752 skills: &skills_json,
1753 })
1754 .await
1755 {
1756 tracing::debug!(
1757 task_id = task.id.0,
1758 ?e,
1759 "dual-write: store_route failed"
1760 );
1761 }
1762 if let Err(e) = store
1763 .update_status(store_id, crate::store::TaskStatus::Routed)
1764 .await
1765 {
1766 tracing::debug!(
1767 task_id = task.id.0,
1768 ?e,
1769 "dual-write: update_status failed"
1770 );
1771 }
1772 if result.estimate > 0 {
1776 let backend_clone = Arc::clone(backend);
1777 let task_id = task.id.clone();
1778 let estimate = result.estimate;
1779 tokio::spawn(async move {
1780 if let Err(e) = backend_clone
1781 .sync_estimate_to_project(&task_id, estimate)
1782 .await
1783 {
1784 tracing::debug!(
1785 task_id = task_id.0,
1786 estimate,
1787 err = %e,
1788 "dual-write: estimate project sync failed"
1789 );
1790 }
1791 });
1792 }
1793 }
1794 Err(e) => {
1795 tracing::debug!(
1796 task_id = task.id.0,
1797 ?e,
1798 "dual-write: ensure_external_task failed"
1799 );
1800 }
1801 }
1802
1803 if let Some(ref warning) = result.warning {
1804 tracing::warn!(task_id = task.id.0, warning, "routing sanity warning");
1805 }
1806
1807 tracing::info!(
1808 task_id = task.id.0,
1809 agent = %result.agent,
1810 complexity = %result.complexity,
1811 reason = %result.reason,
1812 "task routed"
1813 );
1814 }
1815 Err(e) => {
1816 if let Some(cooled) = e.downcast_ref::<AllAgentsCooledError>() {
1817 tracing::error!(
1818 task_id = %task.id.0,
1819 scope = %cooled.scope(),
1820 remaining_secs = ?cooled.remaining_secs(),
1821 "ALL AGENTS COOLED — no agent available to route this task. \
1822 Task stays in 'new' and will be retried on every tick. \
1823 Check `orch cooldown list` to see what is blocking dispatch."
1824 );
1825 continue;
1826 }
1827 if let Some(waiting) = e.downcast_ref::<crate::store::WaitingOnDependency>() {
1828 tracing::info!(
1829 task_id = %task.id.0,
1830 blockers = %waiting.blockers.join(", "),
1831 "task is waiting on a dependency"
1832 );
1833 continue;
1834 }
1835 tracing::error!(task_id = task.id.0, ?e, "routing failed, skipping task");
1836 }
1837 }
1838 }
1839 Ok(())
1840}
1841
1842pub(crate) async fn route_new_tasks_global(
1858 router: &mut Router,
1859 store: &Arc<TaskStore>,
1860 active_repos: &std::collections::HashSet<String>,
1861 routing_quota: &mut RoutingTickQuota,
1862) -> anyhow::Result<()> {
1863 use crate::store::{StoreRoute, TaskStatus};
1864
1865 if routing_quota.is_exhausted() {
1866 tracing::debug!("routing quota exhausted for this tick — skipping global routing sweep");
1867 return Ok(());
1868 }
1869
1870 let all_new = store.list_all_by_status_global(TaskStatus::New).await?;
1871 let orphaned: Vec<&crate::store::Task> = all_new
1872 .iter()
1873 .filter(|t| !active_repos.contains(&t.repo))
1874 .filter(|t| !skips_agent(&t.labels))
1875 .collect();
1876
1877 if orphaned.is_empty() {
1878 return Ok(());
1879 }
1880
1881 tracing::info!(
1882 count = orphaned.len(),
1883 "global routing sweep: found new tasks from inactive repos"
1884 );
1885
1886 router.refresh_health(store).await;
1887
1888 for task in orphaned.into_iter().take(routing_quota.remaining()) {
1889 if !routing_quota.consume_one() {
1890 break;
1891 }
1892 let ext_task = crate::engine::tasks::store_task_to_external(task);
1893 let task_start = Instant::now();
1894 match router.route(&ext_task, store, &task.repo).await {
1895 Ok(result) => {
1896 tracing::debug!(
1897 task_id = %ext_task.id.0,
1898 repo = %task.repo,
1899 duration_ms = task_start.elapsed().as_millis(),
1900 "global sweep: route completed"
1901 );
1902 let profile_json = serde_json::to_string(&result.profile).unwrap_or_default();
1903 let skills_json =
1904 serde_json::to_string(&result.selected_skills).unwrap_or_default();
1905 if let Err(e) = store
1906 .store_route(&StoreRoute {
1907 id: task.id,
1908 agent: &result.agent,
1909 model: result.model.as_deref(),
1910 complexity: &result.complexity,
1911 estimate: result.estimate,
1912 reason: &result.reason,
1913 profile: &profile_json,
1914 skills: &skills_json,
1915 })
1916 .await
1917 {
1918 tracing::warn!(
1919 task_id = %ext_task.id.0,
1920 repo = %task.repo,
1921 err = %e,
1922 "global sweep: failed to store route result; skipping status update"
1923 );
1924 continue;
1925 }
1926 if let Err(e) = store.update_status(task.id, TaskStatus::Routed).await {
1927 tracing::warn!(
1928 task_id = %ext_task.id.0,
1929 repo = %task.repo,
1930 err = %e,
1931 "global sweep: failed to set status:routed"
1932 );
1933 } else {
1934 tracing::info!(
1935 task_id = %ext_task.id.0,
1936 repo = %task.repo,
1937 agent = %result.agent,
1938 complexity = %result.complexity,
1939 "global sweep: task routed"
1940 );
1941 }
1942 if let Some(ref warning) = result.warning {
1943 tracing::warn!(task_id = %ext_task.id.0, warning, "global sweep: routing sanity warning");
1944 }
1945 }
1946 Err(e) => {
1947 if let Some(cooled) = e.downcast_ref::<AllAgentsCooledError>() {
1948 tracing::error!(
1949 task_id = %ext_task.id.0,
1950 repo = %task.repo,
1951 scope = %cooled.scope(),
1952 remaining_secs = ?cooled.remaining_secs(),
1953 "global sweep: ALL AGENTS COOLED — task stays in 'new'"
1954 );
1955 continue;
1956 }
1957 if let Some(waiting) = e.downcast_ref::<crate::store::WaitingOnDependency>() {
1958 tracing::info!(
1959 task_id = %ext_task.id.0,
1960 repo = %task.repo,
1961 blockers = %waiting.blockers.join(", "),
1962 "global sweep: task is waiting on a dependency"
1963 );
1964 continue;
1965 }
1966 tracing::error!(
1967 task_id = %ext_task.id.0,
1968 repo = %task.repo,
1969 err = ?e,
1970 "global sweep: routing failed, skipping task"
1971 );
1972 }
1973 }
1974 }
1975
1976 Ok(())
1977}
1978
1979#[derive(Debug, Clone, Copy)]
1981pub(crate) struct DispatchMode {
1982 pub(crate) is_degraded: bool,
1983 pub(crate) healthy_agents: usize,
1984 pub(crate) threshold: usize,
1985}
1986
1987pub(crate) fn dispatch_mode_from_router(router: &Router) -> DispatchMode {
1988 let threshold = crate::engine::router::config::min_healthy_agents_threshold();
1989 let healthy_agents = router.healthy_agent_count("simple");
1990 let is_degraded = router.is_degraded(threshold);
1991 DispatchMode {
1992 is_degraded,
1993 healthy_agents,
1994 threshold,
1995 }
1996}
1997
1998#[allow(clippy::too_many_arguments)]
2000#[tracing::instrument(skip_all, name = "engine.tick.phase3b.dispatch")]
2001pub(crate) async fn tick_dispatch_tasks(
2002 backend: &Arc<dyn ExternalBackend>,
2003 tmux: &Arc<TmuxManager>,
2004 repo: &str,
2005 runner: &Arc<TaskRunner>,
2006 capture: &Arc<CaptureService>,
2007 semaphore: &Arc<Semaphore>,
2008 task_manager: &Arc<TaskManager>,
2009 weight_tx: &mpsc::Sender<WeightSignal>,
2010 dispatch_mode: DispatchMode,
2011 dispatching: &Arc<DashMap<String, String>>,
2012 store: &Arc<TaskStore>,
2013 session_map: &std::collections::HashMap<String, bool>,
2014) -> anyhow::Result<()> {
2015 let mut routed_tasks = task_manager.list_external_by_status(Status::Routed).await?;
2017
2018 use crate::store::TaskStatus as DbStatus;
2020 let internal_routed = task_manager
2021 .list_internal_by_status(DbStatus::Routed)
2022 .await?;
2023 routed_tasks.extend(internal_routed);
2024
2025 let dispatchable: Vec<ExternalTask> = routed_tasks
2027 .into_iter()
2028 .filter(|t| !skips_agent(&t.labels))
2029 .collect();
2030
2031 dispatch_tasks_for_repo(
2032 backend,
2033 tmux,
2034 repo,
2035 runner,
2036 capture,
2037 semaphore,
2038 task_manager,
2039 weight_tx,
2040 dispatch_mode,
2041 dispatching,
2042 store,
2043 session_map,
2044 dispatchable,
2045 )
2046 .await
2047}
2048
2049#[allow(clippy::too_many_arguments)]
2055async fn dispatch_tasks_for_repo(
2056 backend: &Arc<dyn ExternalBackend>,
2057 tmux: &Arc<TmuxManager>,
2058 repo: &str,
2059 runner: &Arc<TaskRunner>,
2060 capture: &Arc<CaptureService>,
2061 semaphore: &Arc<Semaphore>,
2062 task_manager: &Arc<TaskManager>,
2063 weight_tx: &mpsc::Sender<WeightSignal>,
2064 dispatch_mode: DispatchMode,
2065 dispatching: &Arc<DashMap<String, String>>,
2066 store: &Arc<TaskStore>,
2067 session_map: &std::collections::HashMap<String, bool>,
2068 dispatchable: Vec<ExternalTask>,
2069) -> anyhow::Result<()> {
2070 if dispatchable.is_empty() {
2071 tracing::debug!(count = 0, "dispatchable tasks found");
2072 return Ok(());
2073 }
2074
2075 tracing::info!(count = dispatchable.len(), "dispatchable tasks found");
2076 if dispatch_mode.is_degraded {
2077 tracing::warn!(
2078 healthy_agents = dispatch_mode.healthy_agents,
2079 threshold = dispatch_mode.threshold,
2080 "degraded mode: using sequential dispatch"
2081 );
2082 }
2083
2084 let sequential_delay = if dispatch_mode.is_degraded {
2085 crate::engine::router::config::sequential_dispatch_delay_ms()
2086 } else {
2087 0
2088 };
2089
2090 for (idx, task) in dispatchable.into_iter().enumerate() {
2091 if crate::engine::dependencies::dispatch_is_waiting(store, repo, &task.id.0).await {
2092 continue;
2093 }
2094 if idx > 0 && dispatch_mode.is_degraded {
2096 let delay_ms = sequential_delay;
2097 tracing::debug!(
2098 task_id = task.id.0,
2099 delay_ms = delay_ms,
2100 "sequential dispatch: waiting before dispatch"
2101 );
2102 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
2103 }
2104 let dispatch_key = format!("{}/{}", repo, task.id.0);
2111 {
2116 use dashmap::mapref::entry::Entry;
2117 match dispatching.entry(dispatch_key.clone()) {
2118 Entry::Occupied(existing) => {
2119 let existing_id = existing.get().clone();
2120 drop(existing); if existing_id == task.id.0 {
2122 tracing::debug!(
2123 task_id = task.id.0,
2124 "task already dispatching, skipping duplicate"
2125 );
2126 } else {
2127 tracing::warn!(
2128 task_id = task.id.0,
2129 existing_task_id = existing_id,
2130 dispatch_key,
2131 "dispatch key collision: unexpected task already holds this key"
2132 );
2133 }
2134 continue;
2135 }
2136 Entry::Vacant(slot) => {
2137 slot.insert(task.id.0.clone());
2138 tracing::info!(task_id = task.id.0, dispatch_key, "dispatch guard claimed");
2145 }
2146 }
2147 }
2148 let dispatch_guard = DispatchGuard::new(dispatching.clone(), dispatch_key.clone());
2150 let task_id = task.id.0.clone();
2151
2152 let route_result = match get_route_result(store, repo, &task_id).await {
2155 Ok(r) => Some(r),
2156 Err(RouteResultError::NoAgent { .. }) => {
2157 tracing::warn!(
2158 task_id,
2159 "routed task missing agent — resetting to new for re-routing (#1604)"
2160 );
2161 if let Err(e2) = task_manager.update_task_status(&task.id, Status::New).await {
2162 tracing::error!(task_id, error = %e2, "failed to reset task to new");
2163 }
2164 continue; }
2166 Err(e) => {
2167 tracing::warn!(task_id, error = %e, "get_route_result failed — dispatching without route info");
2168 None
2169 }
2170 };
2171
2172 if let Some(route) = route_result.as_ref() {
2178 let agent_cooled = is_agent_in_cooldown(&route.agent);
2179 let model_cooled = route
2180 .model
2181 .as_deref()
2182 .is_some_and(|model| is_model_in_cooldown(&route.agent, model));
2183 if agent_cooled || model_cooled {
2184 tracing::warn!(
2185 task_id,
2186 agent = route.agent,
2187 model = route.model.as_deref().unwrap_or(""),
2188 "routed agent/model entered cooldown after routing — deferring dispatch to next tick"
2189 );
2190 continue; }
2192 }
2193
2194 let session_name = tmux.session_name(repo, &task_id);
2196 if tmux
2197 .session_blocks_dispatch_from_map(&session_name, session_map)
2198 .await
2199 {
2200 tracing::warn!(
2201 task_id,
2202 session_name,
2203 "task has existing tmux session, skipping dispatch"
2204 );
2205 continue; }
2207
2208 let permit = match semaphore.clone().try_acquire_owned() {
2210 Ok(p) => p,
2211 Err(_) => {
2212 tracing::debug!("all parallel slots busy, skipping remaining tasks");
2213 break; }
2215 };
2216
2217 let set_in_progress_result = task_manager
2219 .update_task_status(&task.id, Status::InProgress)
2220 .await;
2221 if let Err(e) = set_in_progress_result {
2222 tracing::error!(task_id, ?e, "failed to set in_progress, skipping dispatch");
2223 drop(permit);
2224 continue; }
2226 tracing::info!(task_id, "dispatching task");
2227
2228 capture
2230 .register_session(repo, &task_id, &session_name)
2231 .await;
2232
2233 let runner = runner.clone();
2235 let backend = backend.clone();
2236 let tmux = tmux.clone();
2237 let capture = capture.clone();
2238 let task_id_for_cleanup = task_id.clone();
2239 let task_owned = task;
2240 let weight_tx = weight_tx.clone();
2241 let repo_owned = repo.to_string();
2242 let task_manager_for_spawn = task_manager.clone();
2243 let store_for_spawn = store.clone();
2244
2245 let repo_ctx = repo_owned.clone();
2246 tokio::spawn(REPO_CONTEXT.scope(repo_ctx, async move {
2247 let _dispatch_guard = dispatch_guard; let dispatch_start = std::time::Instant::now();
2249 match runner
2250 .run_with_context(&task_owned, &backend, &tmux, route_result.as_ref())
2251 .await
2252 {
2253 Ok(signal) => {
2254 tracing::info!(task_id, "task runner completed");
2255
2256 let duration = dispatch_start.elapsed().as_secs_f64();
2257
2258 let enable_review = config::get("workflow.enable_review_agent")
2264 .map(|v| v != "false")
2265 .unwrap_or(true);
2266 let display_status = match &signal {
2267 WeightSignal::Success { .. } if enable_review => {
2268 let has_pr = match store_for_spawn
2272 .resolve_task_id(&repo_owned, &task_id)
2273 .await
2274 {
2275 Ok(Some(store_id)) => match store_for_spawn
2276 .get(store_id)
2277 .await
2278 {
2279 Ok(t) => t.pr_number.is_some(),
2280 Err(e) => {
2281 tracing::warn!(task_id, err = %e, "store.get() failed — defaulting to needs_review");
2282 true
2283 }
2284 },
2285 Ok(None) => false,
2286 Err(e) => {
2287 tracing::warn!(task_id, err = %e, "failed to check PR status — defaulting to needs_review");
2288 true
2289 }
2290 };
2291 if has_pr {
2292 "needs_review"
2293 } else {
2294 "done"
2295 }
2296 }
2297 WeightSignal::Success { .. } => "done",
2298 WeightSignal::RateLimited { .. } => "new",
2299 WeightSignal::Blocked => "blocked",
2300 WeightSignal::Rerouted => "new",
2305 WeightSignal::None if enable_review => "needs_review",
2310 WeightSignal::None => "done",
2311 };
2312
2313 let _ = weight_tx.send(signal).await;
2315
2316 if display_status == "needs_review" {
2320 if let Err(e) = task_manager_for_spawn
2323 .update_task_status_with_duration(
2324 &ExternalId(task_id.clone()),
2325 Status::NeedsReview,
2326 Some(duration),
2327 )
2328 .await
2329 {
2330 tracing::error!(task_id, err = %e, "update_task_status(NeedsReview) failed — task may be stuck");
2331 }
2332 } else {
2334 let final_status = match display_status {
2336 "done" => Status::Done,
2337 "blocked" => Status::Blocked,
2338 _ => Status::New,
2339 };
2340 if let Err(e) = task_manager_for_spawn
2341 .update_task_status_with_duration(
2342 &ExternalId(task_id.clone()),
2343 final_status,
2344 Some(duration),
2345 )
2346 .await
2347 {
2348 tracing::warn!(task_id, ?e, "failed to update task status after completion");
2349 }
2350 }
2351 }
2352 Err(e) => {
2353 tracing::error!(task_id, ?e, "task runner failed");
2354 let duration = dispatch_start.elapsed().as_secs_f64();
2355 if is_internal_id(&task_id) {
2356 if let Err(ue) = task_manager_for_spawn
2358 .update_task_status_with_duration(
2359 &ExternalId(task_id.clone()),
2360 Status::NeedsReview,
2361 Some(duration),
2362 )
2363 .await
2364 {
2365 tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
2366 }
2367 } else {
2368 if let Err(comment_err) = backend
2370 .post_comment(
2371 &ExternalId(task_id.clone()),
2372 &format!(
2373 "[{}] error: task runner failed: {e}{}",
2374 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
2375 crate::engine::orch_footer()
2376 ),
2377 )
2378 .await
2379 {
2380 tracing::warn!(
2381 task_id,
2382 ?comment_err,
2383 "failed to post error comment to GitHub"
2384 );
2385 }
2386 if let Err(ue) = task_manager_for_spawn
2390 .update_task_status_with_duration(
2391 &ExternalId(task_id.clone()),
2392 Status::NeedsReview,
2393 Some(duration),
2394 )
2395 .await
2396 {
2397 tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
2398 }
2399 }
2400 }
2401 }
2402
2403 capture.unregister_session(&repo_owned, &task_id_for_cleanup).await;
2405
2406 drop(permit);
2408 }));
2410 }
2411 Ok(())
2412}
2413
2414fn skips_agent(labels: &[String]) -> bool {
2415 labels
2416 .iter()
2417 .any(|label| label == "no-agent" || label == "owner:human")
2418}
2419
2420fn group_orphaned_routed_tasks(
2427 all_routed: Vec<crate::store::Task>,
2428 active_repos: &std::collections::HashSet<String>,
2429) -> std::collections::HashMap<String, Vec<crate::store::Task>> {
2430 let mut by_repo: std::collections::HashMap<String, Vec<crate::store::Task>> =
2431 std::collections::HashMap::new();
2432 for task in all_routed
2433 .into_iter()
2434 .filter(|t| !active_repos.contains(&t.repo))
2435 .filter(|t| !skips_agent(&t.labels))
2436 {
2437 by_repo.entry(task.repo.clone()).or_default().push(task);
2438 }
2439 by_repo
2440}
2441
2442#[allow(clippy::too_many_arguments)]
2461pub(crate) async fn dispatch_routed_tasks_global(
2462 tmux: &Arc<TmuxManager>,
2463 capture: &Arc<CaptureService>,
2464 semaphore: &Arc<Semaphore>,
2465 weight_tx: &mpsc::Sender<WeightSignal>,
2466 dispatch_mode: DispatchMode,
2467 dispatching: &Arc<DashMap<String, String>>,
2468 store: &Arc<TaskStore>,
2469 session_map: &std::collections::HashMap<String, bool>,
2470 active_repos: &std::collections::HashSet<String>,
2471) -> anyhow::Result<()> {
2472 use crate::store::TaskStatus;
2473
2474 let all_routed = store.list_all_by_status_global(TaskStatus::Routed).await?;
2475 let by_repo = group_orphaned_routed_tasks(all_routed, active_repos);
2476
2477 if by_repo.is_empty() {
2478 return Ok(());
2479 }
2480
2481 tracing::info!(
2482 repos = by_repo.len(),
2483 "global dispatch sweep: found routed tasks from inactive repos"
2484 );
2485
2486 for (repo, tasks) in by_repo {
2487 let backend: Arc<dyn ExternalBackend> = match crate::backends::github::GitHubBackend::new(
2488 repo.clone(),
2489 ) {
2490 Ok(b) => Arc::new(b),
2491 Err(e) => {
2492 tracing::warn!(repo = %repo, err = %e, "global sweep: failed to construct backend, skipping repo");
2493 continue;
2494 }
2495 };
2496 let task_manager = Arc::new(TaskManager::with_store(
2497 backend.clone(),
2498 store.clone(),
2499 repo.clone(),
2500 ));
2501 let runner = Arc::new(TaskRunner::new(repo.clone()).with_store(store.clone()));
2502 let dispatchable: Vec<ExternalTask> = tasks
2503 .iter()
2504 .map(crate::engine::tasks::store_task_to_external)
2505 .collect();
2506
2507 if let Err(e) = dispatch_tasks_for_repo(
2508 &backend,
2509 tmux,
2510 &repo,
2511 &runner,
2512 capture,
2513 semaphore,
2514 &task_manager,
2515 weight_tx,
2516 dispatch_mode,
2517 dispatching,
2518 store,
2519 session_map,
2520 dispatchable,
2521 )
2522 .await
2523 {
2524 tracing::warn!(repo = %repo, err = %e, "global sweep: dispatch failed for repo");
2525 }
2526 }
2527
2528 Ok(())
2529}
2530
2531pub(crate) async fn tick_unblock_parents(
2533 backend: &Arc<dyn ExternalBackend>,
2534 task_manager: &Arc<TaskManager>,
2535 store: &Arc<TaskStore>,
2536 repo: &str,
2537) -> anyhow::Result<()> {
2538 let blocked = task_manager.list_all_by_status(Status::Blocked).await?;
2539
2540 let store_blocked = store
2544 .list_by_status(repo, crate::store::TaskStatus::Blocked)
2545 .await?;
2546 let has_block_reason: std::collections::HashSet<String> = store_blocked
2547 .iter()
2548 .filter(|t| t.block_reason.is_some())
2549 .filter_map(|t| t.external_id.clone())
2550 .collect();
2551
2552 let handles: Vec<_> = blocked
2556 .into_iter()
2557 .filter(|task| {
2558 if has_block_reason.contains(&task.id.0) {
2559 tracing::debug!(
2560 task_id = task.id.0,
2561 "skipping blocked task with known block_reason"
2562 );
2563 false
2564 } else {
2565 true
2566 }
2567 })
2568 .map(|task| {
2569 let backend_clone = Arc::clone(backend);
2570 let task_manager_clone = Arc::clone(task_manager);
2571 let store_clone = Arc::clone(store);
2572 let repo_owned = repo.to_string();
2573 tokio::spawn(async move {
2574 let task_id = &task.id;
2575
2576 let mut children: Vec<ExternalId> = Vec::new();
2577 let mut child_statuses: std::collections::HashMap<
2578 String,
2579 crate::store::TaskStatus,
2580 > = std::collections::HashMap::new();
2581
2582 match store_clone.resolve_task_id(&repo_owned, &task_id.0).await {
2583 Ok(Some(parent_store_id)) => {
2584 match store_clone.list_children(&repo_owned, parent_store_id).await {
2585 Ok(store_children) => {
2586 for child in store_children {
2587 let Some(child_external_id) = child.external_id else {
2588 continue;
2589 };
2590 child_statuses.insert(child_external_id.clone(), child.status);
2591 children.push(ExternalId(child_external_id));
2592 }
2593 }
2594 Err(e) => {
2595 tracing::debug!(
2596 task_id = task_id.0,
2597 ?e,
2598 "failed to list store-linked children"
2599 );
2600 }
2601 }
2602 }
2603 Ok(None) => {}
2604 Err(e) => {
2605 tracing::debug!(
2606 task_id = task_id.0,
2607 ?e,
2608 "failed to resolve blocked task in store for child lookup"
2609 );
2610 }
2611 }
2612
2613 if !is_internal_id(&task_id.0) {
2614 match backend_clone.get_sub_issues(task_id).await {
2615 Ok(ids) => {
2616 let mut seen: std::collections::HashSet<String> =
2617 children.iter().map(|child| child.0.clone()).collect();
2618 for id in ids {
2619 if seen.insert(id.0.clone()) {
2620 children.push(id);
2621 }
2622 }
2623 }
2624 Err(e) => {
2625 tracing::debug!(task_id = task_id.0, ?e, "failed to get sub-issues");
2626 }
2627 }
2628 }
2629
2630 if children.is_empty() {
2632 return;
2633 }
2634
2635 let mut all_done = true;
2638 let child_ext_strs: Vec<String> =
2640 children.iter().map(|c| c.0.clone()).collect();
2641 let child_exts: Vec<&str> =
2642 child_ext_strs.iter().map(|s| s.as_str()).collect();
2643 if let Ok(map) = store_clone
2645 .get_statuses_by_external_ids(&repo_owned, &child_exts)
2646 .await
2647 {
2648 for (k, v) in map.into_iter() {
2649 child_statuses.insert(k, v);
2650 }
2651 } else {
2652 tracing::debug!(
2653 task_id = task_id.0,
2654 "failed to batch-query store for child statuses; falling back to per-child lookups"
2655 );
2656 }
2657
2658 for child_id in &children {
2659 if let Some(status) = child_statuses.get(&child_id.0) {
2661 if *status != crate::store::TaskStatus::Done {
2662 all_done = false;
2663 break;
2664 }
2665 continue;
2666 }
2667
2668 match backend_clone.get_task(child_id).await {
2670 Ok(child) => {
2671 if !child.labels.iter().any(|l| l == Status::Done.as_label()) {
2672 all_done = false;
2673 break;
2674 }
2675 }
2676 Err(e) => {
2677 tracing::debug!(task_id = task_id.0, child = %child_id.0, ?e, "failed to fetch child task from backend — treating as not done");
2678 all_done = false;
2679 break;
2680 }
2681 }
2682 }
2683
2684 if all_done {
2685 tracing::info!(
2686 task_id = task_id.0,
2687 children = children.len(),
2688 "all children done, unblocking parent"
2689 );
2690 if let Err(e) =
2691 task_manager_clone.update_task_status(task_id, Status::New).await
2692 {
2693 tracing::warn!(task_id = task_id.0, ?e, "failed to unblock parent");
2694 }
2695 }
2696 })
2697 })
2698 .collect();
2699
2700 for handle in handles {
2701 if let Err(e) = handle.await {
2702 tracing::warn!(?e, "phase 4 sub-issue check panicked");
2703 }
2704 }
2705 Ok(())
2706}
2707
2708pub(crate) async fn tick_job_scheduler(
2710 jobs_path: &std::path::Path,
2711 backend: &Arc<dyn ExternalBackend>,
2712 store: Option<&Arc<TaskStore>>,
2713 repo: &str,
2714 transport: Option<&Arc<crate::channels::transport::Transport>>,
2715) -> anyhow::Result<()> {
2716 jobs::tick(jobs_path, backend, store, repo, transport).await
2717}
2718
2719#[allow(clippy::too_many_arguments)]
2729pub(crate) async fn tick_before_routing(
2730 backend: &Arc<dyn ExternalBackend>,
2731 tmux: &Arc<TmuxManager>,
2732 repo: &str,
2733 capture: &Arc<CaptureService>,
2734 config: &EngineConfig,
2735 task_manager: &Arc<TaskManager>,
2736 dispatching: &Arc<DashMap<String, String>>,
2737 store: &Arc<TaskStore>,
2738 session_map: &std::collections::HashMap<String, bool>,
2739) {
2740 let mut phases: Vec<(&str, u128)> = Vec::new();
2741 let mut at = Instant::now();
2742
2743 if let Err(e) = tick_check_session_completions(tmux, repo, capture, store).await {
2744 tracing::warn!(err = %e, "session completion phase failed");
2745 }
2746 phases.push(("sessions", at.elapsed().as_millis()));
2747
2748 if !store.is_leader() {
2751 super::log_phase_timings("tick.pre_routing", repo, &phases);
2752 return;
2753 }
2754
2755 at = Instant::now();
2756 if let Err(e) =
2757 tick_detect_silent_agents(tmux, repo, capture, backend, task_manager, config, store).await
2758 {
2759 tracing::warn!(err = %e, "silent-agent detection phase failed");
2760 }
2761 phases.push(("silent_agents", at.elapsed().as_millis()));
2762
2763 at = Instant::now();
2764 if let Err(e) = tick_recover_stuck_tasks(
2765 backend,
2766 tmux,
2767 repo,
2768 task_manager,
2769 config,
2770 store,
2771 session_map,
2772 dispatching,
2773 )
2774 .await
2775 {
2776 tracing::warn!(err = %e, "stuck-task recovery phase failed");
2777 }
2778 phases.push(("stuck_tasks", at.elapsed().as_millis()));
2779
2780 super::log_phase_timings("tick.pre_routing", repo, &phases);
2781}
2782
2783#[allow(clippy::too_many_arguments)]
2791pub(crate) async fn tick_after_routing(
2792 backend: &Arc<dyn ExternalBackend>,
2793 tmux: &Arc<TmuxManager>,
2794 repo: &str,
2795 runner: &Arc<TaskRunner>,
2796 capture: &Arc<CaptureService>,
2797 semaphore: &Arc<Semaphore>,
2798 project_dir: &std::path::Path,
2799 task_manager: &Arc<TaskManager>,
2800 weight_tx: &mpsc::Sender<WeightSignal>,
2801 dispatch_mode: DispatchMode,
2802 dispatching: &Arc<DashMap<String, String>>,
2803 store: &Arc<TaskStore>,
2804 transport: Option<&Arc<crate::channels::transport::Transport>>,
2805 session_map: &std::collections::HashMap<String, bool>,
2806) {
2807 let mut phases: Vec<(&str, u128)> = Vec::new();
2808 let mut at = Instant::now();
2809
2810 if let Err(e) = tick_dispatch_tasks(
2811 backend,
2812 tmux,
2813 repo,
2814 runner,
2815 capture,
2816 semaphore,
2817 task_manager,
2818 weight_tx,
2819 dispatch_mode,
2820 dispatching,
2821 store,
2822 session_map,
2823 )
2824 .await
2825 {
2826 tracing::warn!(err = %e, "dispatch phase failed");
2827 }
2828 phases.push(("dispatch", at.elapsed().as_millis()));
2829
2830 at = Instant::now();
2831 if let Err(e) = tick_unblock_parents(backend, task_manager, store, repo).await {
2832 tracing::warn!(err = %e, "parent-unblock phase failed");
2833 }
2834 phases.push(("unblock_parents", at.elapsed().as_millis()));
2835
2836 at = Instant::now();
2837 let jobs_path = project_dir.join(".orch.yml");
2838 if let Err(e) = tick_job_scheduler(&jobs_path, backend, Some(store), repo, transport).await {
2839 tracing::error!(?e, "job scheduler tick failed");
2840 }
2841 phases.push(("jobs", at.elapsed().as_millis()));
2842
2843 super::log_phase_timings("tick.post_routing", repo, &phases);
2844}
2845
2846#[allow(clippy::too_many_arguments)]
2856pub(crate) async fn tick_all(
2857 engines: &[ProjectEngine],
2858 tmux: &Arc<TmuxManager>,
2859 capture: &Arc<CaptureService>,
2860 semaphore: &Arc<Semaphore>,
2861 config: &EngineConfig,
2862 router: &Arc<tokio::sync::RwLock<Router>>,
2863 weight_tx: &mpsc::Sender<WeightSignal>,
2864 dispatching: &Arc<DashMap<String, String>>,
2865 transport: Option<&Arc<crate::channels::transport::Transport>>,
2866 active_repos: &std::collections::HashSet<String>,
2867 auto_merge_in_flight: &Arc<DashSet<String>>,
2868) {
2869 let _tick_span = tracing::info_span!("engine.tick").entered();
2870
2871 let Some(first) = engines.first() else {
2872 return;
2873 };
2874 let store = &first.store;
2877
2878 for (project, call_type, calls) in crate::github::http::drain_call_counts()
2880 .into_iter()
2881 .take(10)
2882 {
2883 tracing::debug!(
2884 project,
2885 call_type,
2886 calls,
2887 "github api calls since last tick"
2888 );
2889 }
2890
2891 let session_map = tmux.batch_session_active().await;
2893
2894 let mut passes = Vec::with_capacity(engines.len());
2895 for engine in engines {
2896 passes.push(REPO_CONTEXT.scope(
2897 engine.repo.clone(),
2898 tick_before_routing(
2899 &engine.backend,
2900 tmux,
2901 &engine.repo,
2902 capture,
2903 config,
2904 &engine.task_manager,
2905 dispatching,
2906 &engine.store,
2907 &session_map,
2908 ),
2909 ));
2910 }
2911 futures::future::join_all(passes).await;
2912
2913 if !store.is_leader() {
2914 return;
2915 }
2916
2917 let at = Instant::now();
2921 {
2922 let router_guard = router.read().await;
2923 tick_global_sweeps(
2924 &first.task_manager,
2925 store,
2926 &router_guard,
2927 dispatching,
2928 auto_merge_in_flight,
2929 )
2930 .await;
2931 }
2932 let sweeps_ms = at.elapsed().as_millis();
2933
2934 let at = Instant::now();
2935 recover_untracked_repo_stuck_tasks(
2936 tmux,
2937 config,
2938 store,
2939 &session_map,
2940 dispatching,
2941 active_repos,
2942 )
2943 .await;
2944 let untracked_ms = at.elapsed().as_millis();
2945
2946 let at = Instant::now();
2947 if let Err(err) = crate::engine::dependencies::reconcile_dependencies(
2948 first.backend.as_ref(),
2949 store,
2950 transport.map(|arc| arc.as_ref()),
2951 )
2952 .await
2953 {
2954 tracing::warn!(err = %err, "dependency reconcile failed");
2955 }
2956 super::log_phase_timings(
2957 "tick.global",
2958 &first.repo,
2959 &[
2960 ("global_sweeps", sweeps_ms),
2961 ("untracked_stuck", untracked_ms),
2962 ("dependencies", at.elapsed().as_millis()),
2963 ],
2964 );
2965
2966 {
2967 let mut router_guard = router.write().await;
2968 let mut routing_quota =
2969 RoutingTickQuota::new(crate::engine::router::config::max_tasks_per_routing_tick());
2970 for engine in engines {
2971 let at = Instant::now();
2972 REPO_CONTEXT
2973 .scope(engine.repo.clone(), async {
2974 if let Err(e) = tick_route_tasks(
2975 &engine.backend,
2976 &engine.task_manager,
2977 &mut router_guard,
2978 &engine.store,
2979 &engine.repo,
2980 &mut routing_quota,
2981 )
2982 .await
2983 {
2984 tracing::warn!(err = %e, "routing phase failed");
2985 }
2986 })
2987 .await;
2988 super::log_phase_timings(
2989 "tick.routing",
2990 &engine.repo,
2991 &[("route", at.elapsed().as_millis())],
2992 );
2993 }
2994
2995 if let Err(e) =
2999 route_new_tasks_global(&mut router_guard, store, active_repos, &mut routing_quota).await
3000 {
3001 tracing::warn!(err = %e, "global new-task routing sweep failed");
3002 }
3003 }
3004
3005 let dispatch_mode = dispatch_mode_from_router(&*router.read().await);
3006
3007 let session_map = tmux.batch_session_active().await;
3010
3011 let mut passes = Vec::with_capacity(engines.len());
3012 for engine in engines {
3013 passes.push(REPO_CONTEXT.scope(
3014 engine.repo.clone(),
3015 tick_after_routing(
3016 &engine.backend,
3017 tmux,
3018 &engine.repo,
3019 &engine.runner,
3020 capture,
3021 semaphore,
3022 &engine.project_dir,
3023 &engine.task_manager,
3024 weight_tx,
3025 dispatch_mode,
3026 dispatching,
3027 &engine.store,
3028 transport,
3029 &session_map,
3030 ),
3031 ));
3032 }
3033 futures::future::join_all(passes).await;
3034
3035 if let Err(e) = dispatch_routed_tasks_global(
3041 tmux,
3042 capture,
3043 semaphore,
3044 weight_tx,
3045 dispatch_mode,
3046 dispatching,
3047 store,
3048 &session_map,
3049 active_repos,
3050 )
3051 .await
3052 {
3053 tracing::warn!(err = %e, "global routed-task dispatch sweep failed");
3054 }
3055}
3056
3057#[cfg(test)]
3058mod tests {
3059 use super::*;
3060 use crate::backends::{ExternalId, ExternalTask, Mention, Status};
3061 use crate::channels::transport::Transport;
3062 use crate::engine::tasks::TaskManager;
3063 use crate::store::TaskStore;
3064 use crate::tmux::{SessionGuard, TmuxManager};
3065 use async_trait::async_trait;
3066 use std::sync::{Arc, Mutex};
3067
3068 #[test]
3069 fn skips_agent_for_human_and_no_agent_labels() {
3070 assert!(skips_agent(&["owner:human".to_string()]));
3071 assert!(skips_agent(&["no-agent".to_string()]));
3072 assert!(!skips_agent(&["status:new".to_string()]));
3073 }
3074
3075 fn cooled_review_router_for_test() -> Router {
3079 Router::new_for_test(
3080 crate::engine::router::RouterConfig::default(),
3081 vec!["claude".to_string()],
3082 )
3083 }
3084
3085 struct MockBackend {
3089 blocked_tasks: Vec<ExternalTask>,
3091 sub_issues: std::collections::HashMap<String, Vec<ExternalId>>,
3093 tasks_by_id: std::collections::HashMap<String, ExternalTask>,
3095 get_sub_issues_calls: Arc<Mutex<Vec<String>>>,
3097 status_updates: Arc<Mutex<Vec<(String, Status)>>>,
3099 removed_labels: Arc<Mutex<Vec<(String, String)>>>,
3101 posted_comments: Arc<Mutex<Vec<(String, String)>>>,
3103 }
3104
3105 impl MockBackend {
3106 fn new() -> Self {
3107 Self {
3108 blocked_tasks: vec![],
3109 sub_issues: Default::default(),
3110 tasks_by_id: Default::default(),
3111 get_sub_issues_calls: Arc::new(Mutex::new(vec![])),
3112 status_updates: Arc::new(Mutex::new(vec![])),
3113 removed_labels: Arc::new(Mutex::new(vec![])),
3114 posted_comments: Arc::new(Mutex::new(vec![])),
3115 }
3116 }
3117 }
3118
3119 fn make_task(id: &str, labels: &[&str]) -> ExternalTask {
3120 ExternalTask {
3121 id: ExternalId(id.to_string()),
3122 title: format!("Task {id}"),
3123 body: "".to_string(),
3124 state: "open".to_string(),
3125 labels: labels.iter().map(|s| s.to_string()).collect(),
3126 author: "bot".to_string(),
3127 created_at: "2026-01-01T00:00:00Z".to_string(),
3128 updated_at: "2026-01-01T00:00:00Z".to_string(),
3129 url: format!("https://github.com/test/test/issues/{id}"),
3130 }
3131 }
3132
3133 #[async_trait]
3134 impl ExternalBackend for MockBackend {
3135 fn name(&self) -> &str {
3136 "mock"
3137 }
3138 async fn create_task(
3139 &self,
3140 _title: &str,
3141 _body: &str,
3142 _labels: &[String],
3143 ) -> anyhow::Result<ExternalId> {
3144 Ok(ExternalId("new".to_string()))
3145 }
3146 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
3147 self.tasks_by_id
3148 .get(&id.0)
3149 .cloned()
3150 .ok_or_else(|| anyhow::anyhow!("task not found: {}", id.0))
3151 }
3152 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
3153 if status == Status::Blocked {
3154 Ok(self.blocked_tasks.clone())
3155 } else {
3156 Ok(vec![])
3157 }
3158 }
3159 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
3160 Ok(vec![])
3161 }
3162 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
3163 self.posted_comments
3164 .lock()
3165 .unwrap()
3166 .push((_id.0.clone(), _body.to_string()));
3167 Ok(())
3168 }
3169 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
3170 Ok(())
3171 }
3172 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
3173 self.removed_labels
3174 .lock()
3175 .unwrap()
3176 .push((_id.0.clone(), _label.to_string()));
3177 Ok(())
3178 }
3179 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
3180 self.get_sub_issues_calls.lock().unwrap().push(id.0.clone());
3181 Ok(self.sub_issues.get(&id.0).cloned().unwrap_or_default())
3182 }
3183 async fn create_sub_task(
3184 &self,
3185 _parent: &ExternalId,
3186 _title: &str,
3187 _body: &str,
3188 _labels: &[String],
3189 ) -> anyhow::Result<ExternalId> {
3190 Ok(ExternalId("child".to_string()))
3191 }
3192 async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
3193 Ok(())
3194 }
3195 async fn has_open_issue_with_title(
3196 &self,
3197 _title: &str,
3198 _label: &str,
3199 ) -> anyhow::Result<bool> {
3200 Ok(false)
3201 }
3202 async fn health_check(&self) -> anyhow::Result<()> {
3203 Ok(())
3204 }
3205 async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
3206 Ok(false)
3207 }
3208 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
3209 Ok(Some("testbot".to_string()))
3210 }
3211 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
3212 Ok(vec![])
3213 }
3214 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
3215 self.status_updates
3216 .lock()
3217 .unwrap()
3218 .push((id.0.clone(), status));
3219 Ok(())
3220 }
3221 }
3222
3223 fn make_task_manager(backend: Arc<dyn ExternalBackend>) -> Arc<TaskManager> {
3224 Arc::new(TaskManager::new(backend))
3225 }
3226
3227 #[tokio::test]
3230 async fn unblock_parents_unblocks_when_all_children_done() {
3231 let mut mock = MockBackend::new();
3232
3233 let parent = make_task("10", &["status:blocked"]);
3235 mock.blocked_tasks.push(parent.clone());
3236 mock.sub_issues.insert(
3237 "10".to_string(),
3238 vec![ExternalId("11".to_string()), ExternalId("12".to_string())],
3239 );
3240 mock.tasks_by_id
3241 .insert("11".to_string(), make_task("11", &["status:done"]));
3242 mock.tasks_by_id
3243 .insert("12".to_string(), make_task("12", &["status:done"]));
3244
3245 let status_updates = mock.status_updates.clone();
3246 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3247 let task_manager = make_task_manager(backend.clone());
3248 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3249
3250 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3251 .await
3252 .unwrap();
3253
3254 let updates = status_updates.lock().unwrap();
3255 assert_eq!(updates.len(), 1, "parent should be unblocked");
3256 assert_eq!(updates[0], ("10".to_string(), Status::New));
3257 }
3258
3259 #[tokio::test]
3260 async fn unblock_parents_skips_when_child_not_done() {
3261 let mut mock = MockBackend::new();
3262
3263 let parent = make_task("20", &["status:blocked"]);
3264 mock.blocked_tasks.push(parent.clone());
3265 mock.sub_issues.insert(
3266 "20".to_string(),
3267 vec![ExternalId("21".to_string()), ExternalId("22".to_string())],
3268 );
3269 mock.tasks_by_id
3271 .insert("21".to_string(), make_task("21", &["status:done"]));
3272 mock.tasks_by_id
3273 .insert("22".to_string(), make_task("22", &["status:in_progress"]));
3274
3275 let status_updates = mock.status_updates.clone();
3276 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3277 let task_manager = make_task_manager(backend.clone());
3278 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3279
3280 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3281 .await
3282 .unwrap();
3283
3284 let updates = status_updates.lock().unwrap();
3285 assert!(
3286 updates.is_empty(),
3287 "should not unblock when a child is still running"
3288 );
3289 }
3290
3291 #[tokio::test]
3292 async fn unblock_parents_skips_task_with_no_children() {
3293 let mut mock = MockBackend::new();
3294
3295 let parent = make_task("30", &["status:blocked"]);
3297 mock.blocked_tasks.push(parent.clone());
3298 let status_updates = mock.status_updates.clone();
3301 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3302 let task_manager = make_task_manager(backend.clone());
3303 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3304
3305 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3306 .await
3307 .unwrap();
3308
3309 let updates = status_updates.lock().unwrap();
3310 assert!(
3311 updates.is_empty(),
3312 "should not unblock tasks with no sub-issues"
3313 );
3314 }
3315
3316 #[tokio::test]
3317 async fn unblock_parents_skips_task_with_block_reason_in_store() {
3318 let mut mock = MockBackend::new();
3321
3322 let parent = make_task("60", &["status:blocked"]);
3324 mock.blocked_tasks.push(parent.clone());
3325 mock.sub_issues
3328 .insert("60".to_string(), vec![ExternalId("61".to_string())]);
3329 mock.tasks_by_id
3330 .insert("61".to_string(), make_task("61", &["status:done"]));
3331
3332 let status_updates = mock.status_updates.clone();
3333 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3334 let task_manager = make_task_manager(backend.clone());
3335
3336 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3338 let task_id = store
3339 .upsert_external(&crate::store::UpsertExternal {
3340 repo: "test/repo",
3341 ext_id: "60",
3342 title: "Task 60",
3343 body: "",
3344 author: "bot",
3345 url: "https://github.com/test/test/issues/60",
3346 labels: &[],
3347 origin: "github",
3348 })
3349 .await
3350 .unwrap();
3351 store
3352 .update_status(task_id, crate::store::TaskStatus::Blocked)
3353 .await
3354 .unwrap();
3355 store
3356 .set_block_reason(task_id, Some("CI failure limit reached"))
3357 .await
3358 .unwrap();
3359
3360 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3361 .await
3362 .unwrap();
3363
3364 let updates = status_updates.lock().unwrap();
3365 assert!(
3366 updates.is_empty(),
3367 "task with block_reason should be skipped — no API call, no unblock"
3368 );
3369 }
3370
3371 #[tokio::test]
3372 async fn unblock_parents_unblocks_internal_parent_from_store_children() {
3373 let mock = MockBackend::new();
3374 let get_sub_issues_calls = mock.get_sub_issues_calls.clone();
3375 let status_updates = mock.status_updates.clone();
3376 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3377 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3378 let task_manager = Arc::new(TaskManager::with_store(
3379 backend.clone(),
3380 store.clone(),
3381 "test/repo".to_string(),
3382 ));
3383
3384 let parent_id = store
3385 .create_internal("test/repo", "Parent", "", "job", "daily", None)
3386 .await
3387 .unwrap();
3388 store
3389 .update_status(parent_id, crate::store::TaskStatus::Blocked)
3390 .await
3391 .unwrap();
3392 let child_id = store
3393 .create(&crate::store::NewTask {
3394 external_id: Some("91".to_string()),
3395 repo: "test/repo".to_string(),
3396 origin: "github".to_string(),
3397 title: "Child".to_string(),
3398 body: String::new(),
3399 source: String::new(),
3400 source_id: String::new(),
3401 author: String::new(),
3402 url: String::new(),
3403 labels: vec![],
3404 parent_id: Some(parent_id),
3405 })
3406 .await
3407 .unwrap();
3408 store
3409 .update_status(child_id, crate::store::TaskStatus::Done)
3410 .await
3411 .unwrap();
3412
3413 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3414 .await
3415 .unwrap();
3416
3417 let parent = store.get(parent_id).await.unwrap();
3418 assert_eq!(parent.status, crate::store::TaskStatus::New);
3419 assert!(
3420 status_updates.lock().unwrap().is_empty(),
3421 "internal parent should be updated in store without backend mirroring"
3422 );
3423 assert!(
3424 get_sub_issues_calls.lock().unwrap().is_empty(),
3425 "internal parent should not query backend sub-issues"
3426 );
3427 }
3428
3429 #[cfg(test)]
3433 async fn set_task_updated_at_past(store: &TaskStore, task_id: i64) {
3434 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
3435 .bind(task_id)
3436 .execute(store.pool())
3437 .await
3438 .unwrap();
3439 }
3440
3441 async fn set_in_review_entered_at_past(store: &TaskStore, task_id: i64) {
3443 sqlx::query(
3444 "UPDATE task_activity SET timestamp = '2020-01-01T00:00:00Z'
3445 WHERE task_id = ? AND to_status = 'in_review'",
3446 )
3447 .bind(task_id)
3448 .execute(store.pool())
3449 .await
3450 .unwrap();
3451 }
3452
3453 #[tokio::test]
3454 async fn recover_stuck_tasks_resets_external_in_review_to_needs_review() {
3455 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3456 let mock = MockBackend::new();
3457 let status_updates = mock.status_updates.clone();
3458 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3459 let task_manager = Arc::new(TaskManager::with_store(
3460 backend.clone(),
3461 store.clone(),
3462 "owner/repo".to_string(),
3463 ));
3464 let tmux = Arc::new(TmuxManager::new());
3465 let config = EngineConfig {
3466 no_session_stuck_timeout: 600,
3467 stuck_timeout: 1800,
3468 ..EngineConfig::default()
3469 };
3470
3471 let id = store
3473 .upsert_external(&crate::store::UpsertExternal {
3474 repo: "owner/repo",
3475 ext_id: "99",
3476 title: "Stuck InReview",
3477 body: "",
3478 author: "",
3479 url: "",
3480 labels: &[],
3481 origin: "github",
3482 })
3483 .await
3484 .unwrap();
3485 store
3486 .update_status(id, crate::store::TaskStatus::InReview)
3487 .await
3488 .unwrap();
3489 set_review_session_expected(&store, "owner/repo", "99", true).await;
3490 set_in_review_entered_at_past(&store, id).await;
3491 set_task_updated_at_past(&store, id).await;
3492
3493 tick_recover_stuck_tasks(
3494 &backend,
3495 &tmux,
3496 "owner/repo",
3497 &task_manager,
3498 &config,
3499 &store,
3500 &std::collections::HashMap::new(),
3501 &Arc::new(DashMap::new()),
3502 )
3503 .await
3504 .unwrap();
3505
3506 let updates = status_updates.lock().unwrap();
3507 assert_eq!(updates.len(), 1, "stuck InReview task should be recovered");
3508 assert_eq!(updates[0].0, "99");
3509 assert_eq!(updates[0].1, Status::NeedsReview);
3510 }
3511
3512 #[tokio::test]
3513 async fn recover_stuck_tasks_skips_recent_in_review() {
3514 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3515 let mock = MockBackend::new();
3516 let status_updates = mock.status_updates.clone();
3517 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3518 let task_manager = Arc::new(TaskManager::with_store(
3519 backend.clone(),
3520 store.clone(),
3521 "owner/repo".to_string(),
3522 ));
3523 let tmux = Arc::new(TmuxManager::new());
3524 let config = EngineConfig {
3525 no_session_stuck_timeout: 600, stuck_timeout: 1800,
3527 ..EngineConfig::default()
3528 };
3529
3530 let id = store
3532 .upsert_external(&crate::store::UpsertExternal {
3533 repo: "owner/repo",
3534 ext_id: "100",
3535 title: "Recent InReview",
3536 body: "",
3537 author: "",
3538 url: "",
3539 labels: &[],
3540 origin: "github",
3541 })
3542 .await
3543 .unwrap();
3544 store
3545 .update_status(id, crate::store::TaskStatus::InReview)
3546 .await
3547 .unwrap();
3548 set_review_session_expected(&store, "owner/repo", "100", true).await;
3549 set_task_updated_at_past(&store, id).await;
3550
3551 tick_recover_stuck_tasks(
3552 &backend,
3553 &tmux,
3554 "owner/repo",
3555 &task_manager,
3556 &config,
3557 &store,
3558 &std::collections::HashMap::new(),
3559 &Arc::new(DashMap::new()),
3560 )
3561 .await
3562 .unwrap();
3563
3564 let updates = status_updates.lock().unwrap();
3565 assert!(
3566 updates.is_empty(),
3567 "recent InReview task should not be recovered"
3568 );
3569 }
3570
3571 #[tokio::test]
3572 async fn recover_stuck_tasks_resets_internal_in_review_to_needs_review() {
3573 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3574 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3575 let task_manager = Arc::new(TaskManager::with_store(
3576 backend.clone(),
3577 store.clone(),
3578 "owner/repo".to_string(),
3579 ));
3580 let tmux = Arc::new(TmuxManager::new());
3581 let config = EngineConfig {
3582 no_session_stuck_timeout: 600,
3583 stuck_timeout: 1800,
3584 ..EngineConfig::default()
3585 };
3586
3587 let id = store
3589 .create_internal("owner/repo", "Internal InReview", "", "cron", "1", None)
3590 .await
3591 .unwrap();
3592 store
3593 .update_status(id, crate::store::TaskStatus::InReview)
3594 .await
3595 .unwrap();
3596 set_review_session_expected(&store, "owner/repo", "internal:1", true).await;
3597 set_in_review_entered_at_past(&store, id).await;
3598 set_task_updated_at_past(&store, id).await;
3599
3600 tick_recover_stuck_tasks(
3601 &backend,
3602 &tmux,
3603 "owner/repo",
3604 &task_manager,
3605 &config,
3606 &store,
3607 &std::collections::HashMap::new(),
3608 &Arc::new(DashMap::new()),
3609 )
3610 .await
3611 .unwrap();
3612
3613 let task = store.get(id).await.unwrap();
3614 assert_eq!(
3615 task.status,
3616 crate::store::TaskStatus::NeedsReview,
3617 "stuck internal InReview task should be reset to NeedsReview"
3618 );
3619 }
3620
3621 #[tokio::test]
3622 async fn recover_stuck_tasks_ages_in_review_from_status_entry_not_updated_at() {
3623 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3624 let mock = MockBackend::new();
3625 let status_updates = mock.status_updates.clone();
3626 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3627 let task_manager = Arc::new(TaskManager::with_store(
3628 backend.clone(),
3629 store.clone(),
3630 "owner/repo".to_string(),
3631 ));
3632 let tmux = Arc::new(TmuxManager::new());
3633 let config = EngineConfig {
3634 no_session_stuck_timeout: 600,
3635 stuck_timeout: 1800,
3636 ..EngineConfig::default()
3637 };
3638
3639 let id = store
3640 .upsert_external(&crate::store::UpsertExternal {
3641 repo: "owner/repo",
3642 ext_id: "102",
3643 title: "Fresh updated_at InReview",
3644 body: "",
3645 author: "",
3646 url: "",
3647 labels: &[],
3648 origin: "github",
3649 })
3650 .await
3651 .unwrap();
3652 store
3653 .update_status(id, crate::store::TaskStatus::InReview)
3654 .await
3655 .unwrap();
3656 set_review_session_expected(&store, "owner/repo", "102", true).await;
3657 set_in_review_entered_at_past(&store, id).await;
3658 store
3659 .set_fields(id, &[("pr_number", serde_json::json!(42))])
3660 .await
3661 .unwrap();
3662
3663 tick_recover_stuck_tasks(
3664 &backend,
3665 &tmux,
3666 "owner/repo",
3667 &task_manager,
3668 &config,
3669 &store,
3670 &std::collections::HashMap::new(),
3671 &Arc::new(DashMap::new()),
3672 )
3673 .await
3674 .unwrap();
3675
3676 let updates = status_updates.lock().unwrap();
3677 assert_eq!(
3678 updates.len(),
3679 1,
3680 "in_review stuck recovery must age from the status entry even when updated_at is fresh"
3681 );
3682 assert_eq!(updates[0].0, "102");
3683 assert_eq!(updates[0].1, Status::NeedsReview);
3684 }
3685
3686 #[tokio::test]
3687 async fn recover_stuck_tasks_reclaims_hung_in_review_session() {
3688 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3689 let mock = MockBackend::new();
3690 let status_updates = mock.status_updates.clone();
3691 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3692 let task_manager = Arc::new(TaskManager::with_store(
3693 backend.clone(),
3694 store.clone(),
3695 "owner/repo".to_string(),
3696 ));
3697 let tmux = Arc::new(TmuxManager::new());
3698 let config = EngineConfig {
3699 no_session_stuck_timeout: 600,
3700 stuck_timeout: 1800,
3701 ..EngineConfig::default()
3702 };
3703
3704 let id = store
3705 .upsert_external(&crate::store::UpsertExternal {
3706 repo: "owner/repo",
3707 ext_id: "103",
3708 title: "Hung review session",
3709 body: "",
3710 author: "",
3711 url: "",
3712 labels: &[],
3713 origin: "github",
3714 })
3715 .await
3716 .unwrap();
3717 store
3718 .update_status(id, crate::store::TaskStatus::InReview)
3719 .await
3720 .unwrap();
3721 set_review_session_expected(&store, "owner/repo", "103", true).await;
3722 set_in_review_entered_at_past(&store, id).await;
3723 store
3724 .set_fields(id, &[("pr_number", serde_json::json!(43))])
3725 .await
3726 .unwrap();
3727
3728 let session_name = tmux.session_name("owner/repo", "103-review");
3729 let mut session_map = std::collections::HashMap::new();
3730 session_map.insert(session_name, true);
3731
3732 tick_recover_stuck_tasks(
3733 &backend,
3734 &tmux,
3735 "owner/repo",
3736 &task_manager,
3737 &config,
3738 &store,
3739 &session_map,
3740 &Arc::new(DashMap::new()),
3741 )
3742 .await
3743 .unwrap();
3744
3745 let updates = status_updates.lock().unwrap();
3746 assert_eq!(
3747 updates.len(),
3748 1,
3749 "a live hung review session must be reclaimed once the in_review entry ages past stuck_timeout"
3750 );
3751 assert_eq!(updates[0].0, "103");
3752 assert_eq!(updates[0].1, Status::NeedsReview);
3753 }
3754
3755 #[tokio::test]
3756 async fn untracked_repo_recovery_resets_stale_in_progress_to_new() {
3757 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3758 let tmux = Arc::new(TmuxManager::new());
3759 let config = EngineConfig {
3760 no_session_stuck_timeout: 600,
3761 stuck_timeout: 1800,
3762 ..EngineConfig::default()
3763 };
3764
3765 let id = store
3767 .upsert_external(&crate::store::UpsertExternal {
3768 repo: "owner/other",
3769 ext_id: "777",
3770 title: "Cross-repo stuck task",
3771 body: "",
3772 author: "",
3773 url: "",
3774 labels: &[],
3775 origin: "github",
3776 })
3777 .await
3778 .unwrap();
3779 store
3780 .update_status(id, crate::store::TaskStatus::InProgress)
3781 .await
3782 .unwrap();
3783 set_task_updated_at_past(&store, id).await;
3784
3785 let active = std::collections::HashSet::from(["owner/repo".to_string()]);
3786 recover_untracked_repo_stuck_tasks(
3787 &tmux,
3788 &config,
3789 &store,
3790 &std::collections::HashMap::new(),
3791 &Arc::new(DashMap::new()),
3792 &active,
3793 )
3794 .await;
3795
3796 let task = store.get(id).await.unwrap();
3797 assert_eq!(
3798 task.status,
3799 crate::store::TaskStatus::New,
3800 "cross-repo stale in_progress task should be reset to New"
3801 );
3802 assert_eq!(task.route_attempts, 0, "route attempts should be cleared");
3803 assert!(task.agent.is_none(), "agent should be cleared");
3804 assert!(task.model.is_none(), "model should be cleared");
3805 }
3806
3807 #[tokio::test]
3808 async fn untracked_repo_recovery_leaves_active_repos_to_their_own_pass() {
3809 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3810 let tmux = Arc::new(TmuxManager::new());
3811 let config = EngineConfig {
3812 no_session_stuck_timeout: 600,
3813 stuck_timeout: 1800,
3814 ..EngineConfig::default()
3815 };
3816 let id = store
3817 .upsert_external(&crate::store::UpsertExternal {
3818 repo: "owner/other",
3819 ext_id: "778",
3820 title: "Stuck task in an active repo",
3821 body: "",
3822 author: "",
3823 url: "",
3824 labels: &[],
3825 origin: "github",
3826 })
3827 .await
3828 .unwrap();
3829 store
3830 .update_status(id, crate::store::TaskStatus::InProgress)
3831 .await
3832 .unwrap();
3833 set_task_updated_at_past(&store, id).await;
3834
3835 let active =
3837 std::collections::HashSet::from(["owner/repo".to_string(), "owner/other".to_string()]);
3838 recover_untracked_repo_stuck_tasks(
3839 &tmux,
3840 &config,
3841 &store,
3842 &std::collections::HashMap::new(),
3843 &Arc::new(DashMap::new()),
3844 &active,
3845 )
3846 .await;
3847
3848 assert_eq!(
3849 store.get(id).await.unwrap().status,
3850 crate::store::TaskStatus::InProgress
3851 );
3852 }
3853
3854 #[tokio::test]
3855 async fn recover_stuck_tasks_finalizes_open_task_runs_rows() {
3856 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3857 let mock = MockBackend::new();
3858 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3859 let task_manager = Arc::new(TaskManager::with_store(
3860 backend.clone(),
3861 store.clone(),
3862 "owner/repo".to_string(),
3863 ));
3864 let tmux = Arc::new(TmuxManager::new());
3865 let config = EngineConfig {
3866 no_session_stuck_timeout: 600,
3867 stuck_timeout: 1800,
3868 ..EngineConfig::default()
3869 };
3870
3871 let id = store
3872 .create_internal("owner/repo", "Stuck with open run", "", "cron", "1", None)
3873 .await
3874 .unwrap();
3875 store
3876 .set_fields(
3877 id,
3878 &[
3879 ("agent", serde_json::json!("claude")),
3880 ("model", serde_json::json!("sonnet")),
3881 ("route_attempts", serde_json::json!(1)),
3882 ],
3883 )
3884 .await
3885 .unwrap();
3886 store
3887 .update_status(id, crate::store::TaskStatus::InProgress)
3888 .await
3889 .unwrap();
3890 set_task_updated_at_past(&store, id).await;
3891
3892 store
3894 .start_run(&crate::store::StartRun {
3895 task_id: id,
3896 attempt: 1,
3897 run_type: "agent",
3898 agent: "claude",
3899 model: "sonnet",
3900 command: "claude --model sonnet",
3901 prompt: "fix it",
3902 })
3903 .await
3904 .unwrap();
3905
3906 tick_recover_stuck_tasks(
3907 &backend,
3908 &tmux,
3909 "owner/repo",
3910 &task_manager,
3911 &config,
3912 &store,
3913 &std::collections::HashMap::new(),
3914 &Arc::new(DashMap::new()),
3915 )
3916 .await
3917 .unwrap();
3918
3919 let task = store.get(id).await.unwrap();
3920 assert_eq!(task.status, crate::store::TaskStatus::New);
3921
3922 let runs = store.get_runs(id).await.unwrap();
3923 assert_eq!(runs.len(), 1);
3924 assert_eq!(runs[0].outcome, "aborted");
3925 assert!(runs[0].completed_at.is_some());
3926 assert_eq!(
3927 runs[0].error,
3928 "stuck-task recovery: internal no session found"
3929 );
3930 }
3931
3932 #[tokio::test]
3949 async fn recover_stuck_tasks_skips_reclaim_while_dispatch_guard_held() {
3950 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3951 let mock = MockBackend::new();
3952 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3953 let task_manager = Arc::new(TaskManager::with_store(
3954 backend.clone(),
3955 store.clone(),
3956 "owner/repo".to_string(),
3957 ));
3958 let tmux = Arc::new(TmuxManager::new());
3959 let config = EngineConfig {
3960 no_session_stuck_timeout: 600,
3961 stuck_timeout: 1800,
3962 ..EngineConfig::default()
3963 };
3964
3965 let id = store
3966 .create_internal(
3967 "owner/repo",
3968 "Long-running daily job",
3969 "",
3970 "cron",
3971 "1",
3972 None,
3973 )
3974 .await
3975 .unwrap();
3976 store
3977 .set_fields(
3978 id,
3979 &[
3980 ("agent", serde_json::json!("claude")),
3981 ("model", serde_json::json!("sonnet")),
3982 ("route_attempts", serde_json::json!(1)),
3983 ],
3984 )
3985 .await
3986 .unwrap();
3987 store
3988 .update_status(id, crate::store::TaskStatus::InProgress)
3989 .await
3990 .unwrap();
3991 set_task_updated_at_past(&store, id).await;
3994
3995 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
3998 dispatching.insert(
3999 format!("owner/repo/internal:{id}"),
4000 format!("internal:{id}"),
4001 );
4002
4003 tick_recover_stuck_tasks(
4004 &backend,
4005 &tmux,
4006 "owner/repo",
4007 &task_manager,
4008 &config,
4009 &store,
4010 &std::collections::HashMap::new(),
4011 &dispatching,
4012 )
4013 .await
4014 .unwrap();
4015
4016 let task = store.get(id).await.unwrap();
4017 assert_eq!(
4018 task.status,
4019 crate::store::TaskStatus::InProgress,
4020 "task should not be reclaimed while its dispatch guard is still held"
4021 );
4022
4023 let runs = store.get_runs(id).await.unwrap();
4025 assert!(runs.is_empty());
4026
4027 dispatching.clear();
4030 tick_recover_stuck_tasks(
4031 &backend,
4032 &tmux,
4033 "owner/repo",
4034 &task_manager,
4035 &config,
4036 &store,
4037 &std::collections::HashMap::new(),
4038 &dispatching,
4039 )
4040 .await
4041 .unwrap();
4042 let task = store.get(id).await.unwrap();
4043 assert_eq!(
4044 task.status,
4045 crate::store::TaskStatus::New,
4046 "task should still be reclaimed once the dispatch guard is released"
4047 );
4048 }
4049
4050 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
4066 async fn recover_stuck_tasks_never_reclaims_while_guard_held_under_real_concurrency() {
4067 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4068 let mock = MockBackend::new();
4069 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4070 let task_manager = Arc::new(TaskManager::with_store(
4071 backend.clone(),
4072 store.clone(),
4073 "owner/repo".to_string(),
4074 ));
4075 let tmux = Arc::new(TmuxManager::new());
4076 let config = EngineConfig {
4077 no_session_stuck_timeout: 600,
4078 stuck_timeout: 1800,
4079 ..EngineConfig::default()
4080 };
4081
4082 let id = store
4083 .create_internal(
4084 "owner/repo",
4085 "Long-running daily job",
4086 "",
4087 "cron",
4088 "1",
4089 None,
4090 )
4091 .await
4092 .unwrap();
4093 store
4094 .set_fields(
4095 id,
4096 &[
4097 ("agent", serde_json::json!("claude")),
4098 ("model", serde_json::json!("sonnet")),
4099 ("route_attempts", serde_json::json!(1)),
4100 ],
4101 )
4102 .await
4103 .unwrap();
4104 store
4105 .update_status(id, crate::store::TaskStatus::InProgress)
4106 .await
4107 .unwrap();
4108 set_task_updated_at_past(&store, id).await;
4109
4110 let dispatch_key = format!("owner/repo/internal:{id}");
4111 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4112
4113 let claim_task_id = format!("internal:{id}");
4120 {
4121 use dashmap::mapref::entry::Entry;
4122 match dispatching.entry(dispatch_key.clone()) {
4123 Entry::Vacant(slot) => {
4124 slot.insert(claim_task_id);
4125 }
4126 Entry::Occupied(_) => panic!("dispatch key already claimed"),
4127 }
4128 }
4129 let guard = DispatchGuard::new(Arc::clone(&dispatching), dispatch_key.clone());
4130
4131 let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
4142 let holder = tokio::spawn(async move {
4143 let _ = release_rx.await;
4144 drop(guard);
4145 });
4146
4147 const HAMMER_ITERATIONS: u32 = 300;
4155 for i in 0..HAMMER_ITERATIONS {
4156 tick_recover_stuck_tasks(
4157 &backend,
4158 &tmux,
4159 "owner/repo",
4160 &task_manager,
4161 &config,
4162 &store,
4163 &std::collections::HashMap::new(),
4164 &dispatching,
4165 )
4166 .await
4167 .unwrap();
4168
4169 let task = store.get(id).await.unwrap();
4170 assert_eq!(
4171 task.status,
4172 crate::store::TaskStatus::InProgress,
4173 "task must never be reclaimed while the dispatch guard is deterministically \
4174 still held, even under real cross-thread concurrency (dispatch_key={dispatch_key}, \
4175 iteration={i}, contains_key_now={}, map_len_now={})",
4176 dispatching.contains_key(&dispatch_key),
4177 dispatching.len(),
4178 );
4179 tokio::task::yield_now().await;
4180 }
4181
4182 let _ = release_tx.send(());
4184 holder.await.unwrap();
4185
4186 tick_recover_stuck_tasks(
4188 &backend,
4189 &tmux,
4190 "owner/repo",
4191 &task_manager,
4192 &config,
4193 &store,
4194 &std::collections::HashMap::new(),
4195 &dispatching,
4196 )
4197 .await
4198 .unwrap();
4199 let task = store.get(id).await.unwrap();
4200 assert_eq!(
4201 task.status,
4202 crate::store::TaskStatus::New,
4203 "task should be reclaimed once the dispatch guard is released"
4204 );
4205 }
4206
4207 #[tokio::test]
4208 async fn recover_stuck_tasks_skips_external_in_review_waiting_for_human_review() {
4209 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4210 let mock = MockBackend::new();
4211 let status_updates = mock.status_updates.clone();
4212 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4213 let task_manager = Arc::new(TaskManager::with_store(
4214 backend.clone(),
4215 store.clone(),
4216 "owner/repo".to_string(),
4217 ));
4218 let tmux = Arc::new(TmuxManager::new());
4219 let config = EngineConfig {
4220 no_session_stuck_timeout: 600,
4221 stuck_timeout: 1800,
4222 ..EngineConfig::default()
4223 };
4224
4225 let id = store
4226 .upsert_external(&crate::store::UpsertExternal {
4227 repo: "owner/repo",
4228 ext_id: "101",
4229 title: "Waiting For Human Review",
4230 body: "",
4231 author: "",
4232 url: "",
4233 labels: &[],
4234 origin: "github",
4235 })
4236 .await
4237 .unwrap();
4238 store
4239 .update_status(id, crate::store::TaskStatus::InReview)
4240 .await
4241 .unwrap();
4242 set_task_updated_at_past(&store, id).await;
4243
4244 tick_recover_stuck_tasks(
4245 &backend,
4246 &tmux,
4247 "owner/repo",
4248 &task_manager,
4249 &config,
4250 &store,
4251 &std::collections::HashMap::new(),
4252 &Arc::new(DashMap::new()),
4253 )
4254 .await
4255 .unwrap();
4256
4257 let updates = status_updates.lock().unwrap();
4258 assert!(
4259 updates.is_empty(),
4260 "InReview task waiting for human review should not be reset"
4261 );
4262 }
4263
4264 #[tokio::test]
4265 async fn unblock_parents_no_blocked_tasks() {
4266 let mock = MockBackend::new(); let status_updates = mock.status_updates.clone();
4268 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4269 let task_manager = make_task_manager(backend.clone());
4270 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4271
4272 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
4273 .await
4274 .unwrap();
4275
4276 assert!(status_updates.lock().unwrap().is_empty());
4277 }
4278
4279 #[tokio::test]
4280 async fn unblock_parents_handles_multiple_blocked_tasks() {
4281 let mut mock = MockBackend::new();
4282
4283 let t40 = make_task("40", &["status:blocked"]);
4285 mock.blocked_tasks.push(t40);
4286 mock.sub_issues
4287 .insert("40".to_string(), vec![ExternalId("41".to_string())]);
4288 mock.tasks_by_id
4289 .insert("41".to_string(), make_task("41", &["status:done"]));
4290
4291 let t50 = make_task("50", &["status:blocked"]);
4293 mock.blocked_tasks.push(t50);
4294 mock.sub_issues
4295 .insert("50".to_string(), vec![ExternalId("51".to_string())]);
4296 mock.tasks_by_id
4297 .insert("51".to_string(), make_task("51", &["status:in_progress"]));
4298
4299 let status_updates = mock.status_updates.clone();
4300 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4301 let task_manager = make_task_manager(backend.clone());
4302 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4303
4304 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
4305 .await
4306 .unwrap();
4307
4308 let updates = status_updates.lock().unwrap();
4309 assert_eq!(updates.len(), 1, "only one parent should be unblocked");
4310 assert_eq!(updates[0].0, "40", "task 40 should be unblocked");
4311 assert_eq!(updates[0].1, Status::New);
4312 }
4313
4314 #[tokio::test]
4317 async fn tick_all_is_a_no_op_without_projects() {
4318 let transport = Arc::new(Transport::new());
4319 let capture = Arc::new(CaptureService::new(transport.clone()));
4320 let tmux = Arc::new(TmuxManager::new());
4321 let semaphore = Arc::new(Semaphore::new(1));
4322 let router = Arc::new(tokio::sync::RwLock::new(cooled_review_router_for_test()));
4323 let (weight_tx, mut weight_rx) = mpsc::channel(8);
4324 let dispatching = Arc::new(DashMap::new());
4325 let auto_merge_in_flight = Arc::new(DashSet::new());
4326
4327 tick_all(
4328 &[],
4329 &tmux,
4330 &capture,
4331 &semaphore,
4332 &EngineConfig::default(),
4333 &router,
4334 &weight_tx,
4335 &dispatching,
4336 Some(&transport),
4337 &std::collections::HashSet::new(),
4338 &auto_merge_in_flight,
4339 )
4340 .await;
4341
4342 assert!(dispatching.is_empty(), "no project, so no dispatch claimed");
4343 assert!(
4344 weight_rx.try_recv().is_err(),
4345 "no project, so no weight signal"
4346 );
4347 }
4348
4349 #[serial_test::serial(cooldown_state)]
4354 #[tokio::test]
4355 async fn tick_all_runs_the_global_needs_review_sweep_once_for_two_projects() {
4356 use crate::engine::router::{Router, RouterConfig};
4357 use crate::store::UpsertExternal;
4358
4359 crate::engine::cooldown::reset_global_state().await;
4360
4361 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4362 let id = store
4363 .upsert_external(&UpsertExternal {
4364 repo: "owner/one",
4365 ext_id: "7",
4366 title: "Stale needs_review task",
4367 body: "",
4368 author: "user",
4369 url: "https://github.com/owner/one/issues/7",
4370 labels: &[],
4371 origin: "github",
4372 })
4373 .await
4374 .unwrap();
4375 store
4376 .update_status(id, crate::store::TaskStatus::NeedsReview)
4377 .await
4378 .unwrap();
4379 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4380 .bind(id)
4381 .execute(store.pool())
4382 .await
4383 .unwrap();
4384
4385 let engines: Vec<ProjectEngine> = ["owner/one", "owner/two"]
4386 .iter()
4387 .map(|repo| {
4388 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4389 ProjectEngine {
4390 repo: repo.to_string(),
4391 project_dir: std::path::PathBuf::from("/nonexistent"),
4392 backend: backend.clone(),
4393 task_manager: Arc::new(TaskManager::with_store(
4394 backend,
4395 store.clone(),
4396 repo.to_string(),
4397 )),
4398 runner: Arc::new(crate::engine::runner::TaskRunner::new(repo.to_string())),
4399 store: store.clone(),
4400 }
4401 })
4402 .collect();
4403
4404 let mut config = RouterConfig::default();
4406 config
4407 .model_map
4408 .entry("review".to_string())
4409 .or_default()
4410 .insert("claude".to_string(), vec!["sonnet".to_string()]);
4411 let router = Arc::new(tokio::sync::RwLock::new(Router::new_for_test(
4412 config,
4413 vec!["claude".to_string()],
4414 )));
4415
4416 let transport = Arc::new(Transport::new());
4417 let capture = Arc::new(CaptureService::new(transport.clone()));
4418 let (weight_tx, _weight_rx) = mpsc::channel(8);
4419
4420 tick_all(
4421 &engines,
4422 &Arc::new(TmuxManager::new()),
4423 &capture,
4424 &Arc::new(Semaphore::new(1)),
4425 &EngineConfig::default(),
4426 &router,
4427 &weight_tx,
4428 &Arc::new(DashMap::new()),
4429 Some(&transport),
4430 &engines.iter().map(|e| e.repo.clone()).collect(),
4431 &Arc::new(DashSet::new()),
4432 )
4433 .await;
4434
4435 assert_eq!(
4436 store.get(id).await.unwrap().needs_review_refires,
4437 1,
4438 "two projects must share one pass of the global NeedsReview sweep"
4439 );
4440 }
4441
4442 #[tokio::test]
4445 async fn tick_all_does_not_kill_sessions_it_did_not_create() {
4446 let tmux = Arc::new(TmuxManager::new());
4447 let session_name = match tmux
4448 .create_session("other/foreign", "3889", "/tmp", "sleep 30", &[])
4449 .await
4450 {
4451 Ok(name) => name,
4452 Err(_) => {
4453 eprintln!("Skipping test: tmux not available or failed to create test session");
4454 return;
4455 }
4456 };
4457 let _guard = SessionGuard(session_name.clone());
4458
4459 tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
4462
4463 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4464 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
4465 let engines = vec![ProjectEngine {
4466 repo: "owner/one".to_string(),
4467 project_dir: std::path::PathBuf::from("/nonexistent"),
4468 backend: backend.clone(),
4469 task_manager: Arc::new(TaskManager::with_store(
4470 backend,
4471 store.clone(),
4472 "owner/one".to_string(),
4473 )),
4474 runner: Arc::new(crate::engine::runner::TaskRunner::new(
4475 "owner/one".to_string(),
4476 )),
4477 store,
4478 }];
4479
4480 let transport = Arc::new(Transport::new());
4481 let capture = Arc::new(CaptureService::new(transport.clone()));
4482 let (weight_tx, _weight_rx) = mpsc::channel(8);
4483 let router = Arc::new(tokio::sync::RwLock::new(cooled_review_router_for_test()));
4484
4485 tick_all(
4486 &engines,
4487 &tmux,
4488 &capture,
4489 &Arc::new(Semaphore::new(1)),
4490 &EngineConfig::default(),
4491 &router,
4492 &weight_tx,
4493 &Arc::new(DashMap::new()),
4494 Some(&transport),
4495 &engines.iter().map(|e| e.repo.clone()).collect(),
4496 &Arc::new(DashSet::new()),
4497 )
4498 .await;
4499
4500 assert!(
4501 tmux.session_exists(&session_name).await,
4502 "tick_all must not kill a tmux session it did not start"
4503 );
4504 }
4505
4506 #[tokio::test]
4507 async fn detect_silent_agents_skips_backend_for_internal_tasks() {
4508 let transport = Arc::new(Transport::new());
4509 let capture = Arc::new(CaptureService::new(transport));
4510 let mock = Arc::new(MockBackend::new());
4511 let backend: Arc<dyn ExternalBackend> = mock.clone();
4512 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4513 let task_manager = Arc::new(TaskManager::with_store(
4514 backend.clone(),
4515 store.clone(),
4516 "owner/repo".to_string(),
4517 ));
4518 let tmux = Arc::new(TmuxManager::new());
4519 let config = EngineConfig {
4520 silence_grace_period: 0,
4521 ..EngineConfig::default()
4522 };
4523
4524 let internal_id = store
4525 .create_internal("owner/repo", "Silent internal", "", "cron", "1", None)
4526 .await
4527 .unwrap();
4528 store
4529 .update_status(internal_id, crate::store::TaskStatus::InProgress)
4530 .await
4531 .unwrap();
4532
4533 let task_id = format!("internal:{internal_id}");
4534 capture
4535 .register_session("owner/repo", &task_id, "orch-test-internal")
4536 .await;
4537 tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
4538
4539 tick_detect_silent_agents(
4540 &tmux,
4541 "owner/repo",
4542 &capture,
4543 &backend,
4544 &task_manager,
4545 &config,
4546 &store,
4547 )
4548 .await
4549 .unwrap();
4550
4551 assert!(
4552 mock.removed_labels.lock().unwrap().is_empty(),
4553 "internal tasks should skip backend label removal"
4554 );
4555 assert!(
4556 mock.posted_comments.lock().unwrap().is_empty(),
4557 "internal tasks should skip backend comments"
4558 );
4559
4560 let task = store.get(internal_id).await.unwrap();
4561 assert!(
4565 matches!(
4566 task.status,
4567 crate::store::TaskStatus::Routed | crate::store::TaskStatus::New
4568 ),
4569 "internal task should be routed to fallback or reset to new, got {:?}",
4570 task.status
4571 );
4572 }
4573
4574 #[tokio::test]
4575 async fn detect_silent_agents_finalizes_open_task_runs_rows() {
4576 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4577 let mock = MockBackend::new();
4578 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4579 let task_manager = Arc::new(TaskManager::with_store(
4580 backend.clone(),
4581 store.clone(),
4582 "owner/repo".to_string(),
4583 ));
4584 let transport = Arc::new(Transport::new());
4585 let capture = Arc::new(CaptureService::new(transport));
4586 let tmux = Arc::new(TmuxManager::new());
4587 let config = EngineConfig {
4588 silence_grace_period: 60,
4589 silence_cooldown: 300,
4590 ..EngineConfig::default()
4591 };
4592
4593 let internal_id = store
4594 .create_internal("owner/repo", "Silent with open run", "", "cron", "1", None)
4595 .await
4596 .unwrap();
4597 store
4598 .set_fields(
4599 internal_id,
4600 &[
4601 ("agent", serde_json::json!("claude")),
4602 ("model", serde_json::json!("sonnet")),
4603 ],
4604 )
4605 .await
4606 .unwrap();
4607 store
4608 .update_status(internal_id, crate::store::TaskStatus::InProgress)
4609 .await
4610 .unwrap();
4611
4612 let task_id = format!("internal:{internal_id}");
4613 capture
4614 .register_session("owner/repo", &task_id, "orch-test-silent-run")
4615 .await;
4616 capture
4617 .set_buffer_state_for_test(
4618 "owner/repo",
4619 &task_id,
4620 false,
4621 false,
4622 chrono::Utc::now() - chrono::Duration::seconds(120),
4623 )
4624 .await;
4625
4626 store
4628 .start_run(&crate::store::StartRun {
4629 task_id: internal_id,
4630 attempt: 1,
4631 run_type: "agent",
4632 agent: "claude",
4633 model: "sonnet",
4634 command: "claude --model sonnet",
4635 prompt: "fix it",
4636 })
4637 .await
4638 .unwrap();
4639
4640 tick_detect_silent_agents(
4641 &tmux,
4642 "owner/repo",
4643 &capture,
4644 &backend,
4645 &task_manager,
4646 &config,
4647 &store,
4648 )
4649 .await
4650 .unwrap();
4651
4652 let runs = store.get_runs(internal_id).await.unwrap();
4653 assert_eq!(runs.len(), 1);
4654 assert_eq!(runs[0].outcome, "aborted");
4655 assert!(runs[0].completed_at.is_some());
4656 assert_eq!(runs[0].error, "silence detection: session killed");
4657 }
4658
4659 #[tokio::test]
4670 async fn phase1_session_completion_touch_prevents_phase2_reclaim() {
4671 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4672 let mock = MockBackend::new();
4673 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4674 let task_manager = Arc::new(TaskManager::with_store(
4675 backend.clone(),
4676 store.clone(),
4677 "owner/repo".to_string(),
4678 ));
4679 let tmux = Arc::new(TmuxManager::new());
4680 let config = EngineConfig {
4681 no_session_stuck_timeout: 600,
4682 stuck_timeout: 1800,
4683 ..EngineConfig::default()
4684 };
4685
4686 let id = store
4690 .create_internal("owner/repo", "Completed task", "", "cron", "1", None)
4691 .await
4692 .unwrap();
4693 store
4694 .update_status(id, crate::store::TaskStatus::InProgress)
4695 .await
4696 .unwrap();
4697 set_task_updated_at_past(&store, id).await;
4698
4699 let task_id = format!("internal:{id}");
4702 store_touch_updated_at(&Some(Arc::clone(&store)), "owner/repo", &task_id).await;
4703
4704 tick_recover_stuck_tasks(
4706 &backend,
4707 &tmux,
4708 "owner/repo",
4709 &task_manager,
4710 &config,
4711 &store,
4712 &std::collections::HashMap::new(),
4713 &Arc::new(DashMap::new()),
4714 )
4715 .await
4716 .unwrap();
4717
4718 let task = store.get(id).await.unwrap();
4719 assert_eq!(
4720 task.status,
4721 crate::store::TaskStatus::InProgress,
4722 "Phase 2 must not reclaim a task whose updated_at was just touched by Phase 1"
4723 );
4724 }
4725
4726 #[tokio::test]
4727 async fn stuck_task_timing_treats_dead_session_as_missing() {
4728 let tmux = Arc::new(TmuxManager::new());
4729 let repo = "owner/repo";
4730 let task_id = "internal:33498";
4731 let session_name = tmux.session_name(repo, task_id);
4732
4733 let create_result = tmux
4734 .command()
4735 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
4736 .output()
4737 .await;
4738
4739 match create_result {
4740 Ok(o) if o.status.success() => {}
4741 _ => {
4742 eprintln!("Skipping test: tmux not available or failed to create test session");
4743 return;
4744 }
4745 }
4746
4747 let _guard = SessionGuard(session_name.clone());
4748
4749 let set_option_result = tmux
4750 .command()
4751 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
4752 .output()
4753 .await;
4754 if !matches!(set_option_result, Ok(ref o) if o.status.success()) {
4755 eprintln!("Skipping test: unable to set tmux remain-on-exit option");
4756 return;
4757 }
4758
4759 let send_exit_result = tmux
4760 .command()
4761 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
4762 .output()
4763 .await;
4764 if !matches!(send_exit_result, Ok(ref o) if o.status.success()) {
4765 eprintln!("Skipping test: unable to exit tmux pane");
4766 return;
4767 }
4768
4769 for _ in 0..20 {
4771 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4772 if !tmux.session_is_running(&session_name).await {
4773 break;
4774 }
4775 }
4776
4777 let mut session_map = tmux.batch_session_active().await;
4782 for _ in 0..20 {
4783 if !session_map.get(&session_name).copied().unwrap_or(false) {
4784 break;
4785 }
4786 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4787 session_map = tmux.batch_session_active().await;
4788 }
4789 if session_map.get(&session_name).copied().unwrap_or(false) {
4790 eprintln!("Skipping test: session still alive in batch_session_active after retries (CI tmux lag)");
4791 return;
4792 }
4793
4794 let config = EngineConfig {
4795 no_session_stuck_timeout: 600,
4796 stuck_timeout: 1800,
4797 ..EngineConfig::default()
4798 };
4799 let updated_at = (chrono::Utc::now()
4800 - chrono::Duration::seconds(config.no_session_stuck_timeout as i64 + 5))
4801 .to_rfc3339();
4802
4803 let timing = stuck_task_timing_from_map(
4804 &tmux,
4805 repo,
4806 task_id,
4807 &updated_at,
4808 &config,
4809 "parse failure",
4810 &session_map,
4811 )
4812 .expect("dead session should be treated as missing");
4813
4814 assert!(!timing.has_session);
4815 assert_eq!(timing.threshold, config.no_session_stuck_timeout);
4816 }
4817
4818 #[tokio::test]
4819 #[serial_test::serial(suspend_state)]
4820 async fn stuck_task_timing_discounts_host_suspend_gap() {
4821 super::super::suspend::clear_for_test();
4822
4823 let tmux = Arc::new(TmuxManager::new());
4824 let repo = "owner/repo";
4825 let task_id = "internal:99001";
4826 let session_map = std::collections::HashMap::new();
4827
4828 let config = EngineConfig {
4829 no_session_stuck_timeout: 1800,
4830 stuck_timeout: 1800,
4831 ..EngineConfig::default()
4832 };
4833
4834 let updated_at = (chrono::Utc::now() - chrono::Duration::minutes(33)).to_rfc3339();
4838 let suspend_gap_detected_at = chrono::Utc::now() - chrono::Duration::minutes(1);
4839 super::super::suspend::inject_gap_for_test(
4840 suspend_gap_detected_at,
4841 chrono::Duration::minutes(30),
4842 );
4843
4844 let timing = stuck_task_timing_from_map(
4845 &tmux,
4846 repo,
4847 task_id,
4848 &updated_at,
4849 &config,
4850 "parse failure",
4851 &session_map,
4852 );
4853
4854 assert!(
4855 timing.is_none(),
4856 "task age should be discounted below the stuck threshold once suspend time is subtracted, got {timing:?}"
4857 );
4858
4859 super::super::suspend::clear_for_test();
4860 }
4861
4862 #[tokio::test]
4863 async fn recover_stuck_tasks_reclaims_routed_task_blocked_by_stale_session() {
4864 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4865 let mock = MockBackend::new();
4866 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4867 let task_manager = Arc::new(TaskManager::with_store(
4868 backend.clone(),
4869 store.clone(),
4870 "owner/repo".to_string(),
4871 ));
4872 let tmux = Arc::new(TmuxManager::new());
4873 let config = EngineConfig {
4874 no_session_stuck_timeout: 600,
4875 stuck_timeout: 1,
4876 ..EngineConfig::default()
4877 };
4878
4879 let id = store
4880 .create_internal("owner/repo", "Blocked routed task", "", "cron", "1", None)
4881 .await
4882 .unwrap();
4883 store
4884 .update_status(id, crate::store::TaskStatus::Routed)
4885 .await
4886 .unwrap();
4887 set_task_updated_at_past(&store, id).await;
4888
4889 let task_id = format!("internal:{id}");
4890 let session_name = tmux.session_name("owner/repo", &task_id);
4891 let create_result = tmux
4892 .command()
4893 .args([
4894 "new-session",
4895 "-d",
4896 "-s",
4897 &session_name,
4898 "-c",
4899 "/tmp",
4900 "sleep 60",
4901 ])
4902 .output()
4903 .await;
4904
4905 match create_result {
4906 Ok(o) if o.status.success() => {}
4907 _ => {
4908 eprintln!("Skipping test: tmux not available or failed to create test session");
4909 return;
4910 }
4911 }
4912
4913 let session_map = tmux.batch_session_active().await;
4914 assert_eq!(session_map.get(&session_name).copied(), Some(true));
4915
4916 tick_recover_stuck_tasks(
4917 &backend,
4918 &tmux,
4919 "owner/repo",
4920 &task_manager,
4921 &config,
4922 &store,
4923 &session_map,
4924 &Arc::new(DashMap::new()),
4925 )
4926 .await
4927 .unwrap();
4928
4929 let task = store.get(id).await.unwrap();
4930 assert_eq!(
4931 task.status,
4932 crate::store::TaskStatus::New,
4933 "routed task blocked by a stale session should be reset for re-routing"
4934 );
4935 assert!(
4936 !tmux.session_exists(&session_name).await,
4937 "recovery should kill the stale tmux session so dispatch can proceed"
4938 );
4939 }
4940
4941 #[tokio::test]
4955 async fn dispatch_does_not_deadlock_under_write_lock() {
4956 use crate::engine::router::Router;
4957 use tokio::sync::RwLock;
4958
4959 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4960 let mock = MockBackend::new();
4961 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4962 let task_manager = Arc::new(TaskManager::with_store(
4963 backend.clone(),
4964 store.clone(),
4965 "owner/repo".to_string(),
4966 ));
4967 let tmux = Arc::new(TmuxManager::new());
4968 let semaphore = Arc::new(Semaphore::new(4));
4969 let (weight_tx, _weight_rx) = mpsc::channel(16);
4970 let dispatching = Arc::new(DashMap::new());
4971 let transport = Arc::new(Transport::new());
4972 let capture = Arc::new(CaptureService::new(transport));
4973
4974 let router_config = crate::engine::router::RouterConfig::default();
4975 let router = Router::new(router_config);
4976 let router_arc = Arc::new(RwLock::new(router));
4977
4978 let write_guard = router_arc.write().await;
4982 let dispatch_mode = dispatch_mode_from_router(&write_guard);
4983
4984 let result = tokio::time::timeout(
4985 std::time::Duration::from_secs(2),
4986 tick_dispatch_tasks(
4987 &backend,
4988 &tmux,
4989 "owner/repo",
4990 &Arc::new(crate::engine::runner::TaskRunner::new(
4991 "owner/repo".to_string(),
4992 )),
4993 &capture,
4994 &semaphore,
4995 &task_manager,
4996 &weight_tx,
4997 dispatch_mode,
4998 &dispatching,
4999 &store,
5000 &std::collections::HashMap::new(),
5001 ),
5002 )
5003 .await;
5004
5005 assert!(
5006 result.is_ok(),
5007 "tick_dispatch_tasks deadlocked! It tried to acquire a read lock \
5008 on router_arc while a write lock was already held (issue #1361)"
5009 );
5010 }
5011
5012 #[tokio::test]
5020 async fn dispatch_skips_task_whose_routed_model_entered_cooldown() {
5021 use crate::store::{StoreRoute, TaskStatus as DbStatus, UpsertExternal};
5022
5023 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5024 let mock = MockBackend::new();
5025 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5026 let task_manager = Arc::new(TaskManager::with_store(
5027 backend.clone(),
5028 store.clone(),
5029 "owner/repo".to_string(),
5030 ));
5031 let tmux = Arc::new(TmuxManager::new());
5032 let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
5033 let semaphore = Arc::new(Semaphore::new(4));
5034 let (weight_tx, _weight_rx) = mpsc::channel(16);
5035 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5036 let transport = Arc::new(Transport::new());
5037 let capture = Arc::new(CaptureService::new(transport));
5038
5039 let id = store
5040 .upsert_external(&UpsertExternal {
5041 repo: "owner/repo",
5042 ext_id: "999",
5043 title: "Stale routed task",
5044 body: "",
5045 author: "user",
5046 url: "https://github.com/owner/repo/issues/999",
5047 labels: &[],
5048 origin: "github",
5049 })
5050 .await
5051 .unwrap();
5052 store
5053 .store_route(&StoreRoute {
5054 id,
5055 agent: "minimax",
5056 model: Some("opus"),
5057 complexity: "medium",
5058 estimate: 0,
5059 reason: "test",
5060 profile: "{}",
5061 skills: "[]",
5062 })
5063 .await
5064 .unwrap();
5065 store.update_status(id, DbStatus::Routed).await.unwrap();
5066
5067 crate::engine::cooldown::set_model_cooldown(
5070 "minimax",
5071 "opus",
5072 300,
5073 "rate_limit_usage_window",
5074 )
5075 .await;
5076
5077 let dispatch_mode = DispatchMode {
5078 is_degraded: false,
5079 healthy_agents: 1,
5080 threshold: 1,
5081 };
5082
5083 tick_dispatch_tasks(
5084 &backend,
5085 &tmux,
5086 "owner/repo",
5087 &runner,
5088 &capture,
5089 &semaphore,
5090 &task_manager,
5091 &weight_tx,
5092 dispatch_mode,
5093 &dispatching,
5094 &store,
5095 &std::collections::HashMap::new(),
5096 )
5097 .await
5098 .unwrap();
5099
5100 let task = store.get(id).await.unwrap();
5101 assert_eq!(
5102 task.status,
5103 DbStatus::Routed,
5104 "task whose routed model is now cooled down must stay Routed, not be dispatched"
5105 );
5106 assert!(
5107 dispatching.is_empty(),
5108 "dispatch guard must be released when skipping a stale-cooldown task"
5109 );
5110 }
5111
5112 #[tokio::test]
5113 async fn dispatch_skips_a_task_with_an_open_dependency() {
5114 use crate::store::{
5115 DepSource, NewDependency, StoreRoute, TaskStatus as DbStatus, UpsertExternal,
5116 };
5117
5118 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5119 let mock = MockBackend::new();
5120 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5121 let task_manager = Arc::new(TaskManager::with_store(
5122 backend.clone(),
5123 store.clone(),
5124 "owner/repo".to_string(),
5125 ));
5126 let tmux = Arc::new(TmuxManager::new());
5127 let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
5128 let semaphore = Arc::new(Semaphore::new(4));
5129 let (weight_tx, _weight_rx) = mpsc::channel(16);
5130 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
5131 let transport = Arc::new(Transport::new());
5132 let capture = Arc::new(CaptureService::new(transport));
5133
5134 let id = store
5135 .upsert_external(&UpsertExternal {
5136 repo: "owner/repo",
5137 ext_id: "42",
5138 title: "Waiting routed task",
5139 body: "",
5140 author: "user",
5141 url: "https://github.com/owner/repo/issues/42",
5142 labels: &[],
5143 origin: "github",
5144 })
5145 .await
5146 .unwrap();
5147 store
5148 .replace_dependencies(
5149 id,
5150 &[NewDependency {
5151 repo: "owner/repo".into(),
5152 number: 7,
5153 source: DepSource::Github,
5154 }],
5155 )
5156 .await
5157 .unwrap();
5158 store
5159 .store_route(&StoreRoute {
5160 id,
5161 agent: "claude",
5162 model: Some("haiku"),
5163 complexity: "simple",
5164 estimate: 0,
5165 reason: "test",
5166 profile: "{}",
5167 skills: "[]",
5168 })
5169 .await
5170 .unwrap();
5171 store.update_status(id, DbStatus::Routed).await.unwrap();
5172
5173 let dispatch_mode = DispatchMode {
5174 is_degraded: false,
5175 healthy_agents: 1,
5176 threshold: 1,
5177 };
5178 tick_dispatch_tasks(
5179 &backend,
5180 &tmux,
5181 "owner/repo",
5182 &runner,
5183 &capture,
5184 &semaphore,
5185 &task_manager,
5186 &weight_tx,
5187 dispatch_mode,
5188 &dispatching,
5189 &store,
5190 &std::collections::HashMap::new(),
5191 )
5192 .await
5193 .unwrap();
5194
5195 let task = store.get(id).await.unwrap();
5196 assert_eq!(task.status, DbStatus::Waiting);
5197 assert!(dispatching.is_empty());
5198 }
5199
5200 #[tokio::test]
5213 async fn completed_session_does_not_cause_stuck_reclaim_race() {
5214 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5215 let mock = MockBackend::new();
5216 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
5217 let task_manager = Arc::new(TaskManager::with_store(
5218 backend.clone(),
5219 store.clone(),
5220 "owner/repo".to_string(),
5221 ));
5222 let tmux = Arc::new(TmuxManager::new());
5223 let transport = Arc::new(Transport::new());
5224 let capture = Arc::new(CaptureService::new(transport));
5225
5226 let id = store
5228 .upsert_external(&crate::store::UpsertExternal {
5229 repo: "owner/repo",
5230 ext_id: "200",
5231 title: "Race Task",
5232 body: "",
5233 author: "",
5234 url: "",
5235 labels: &[],
5236 origin: "github",
5237 })
5238 .await
5239 .unwrap();
5240 store
5241 .update_status(id, crate::store::TaskStatus::InProgress)
5242 .await
5243 .unwrap();
5244
5245 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
5247 .bind(id)
5248 .execute(store.pool())
5249 .await
5250 .unwrap();
5251
5252 let task_id = "200";
5254 let session_name = tmux.session_name("owner/repo", task_id);
5255 let create_result = tmux
5256 .command()
5257 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
5258 .output()
5259 .await;
5260 match create_result {
5261 Ok(o) if o.status.success() => {}
5262 _ => {
5263 eprintln!("Skipping test: tmux not available or failed to create test session");
5264 return;
5265 }
5266 }
5267 let _guard = SessionGuard(session_name.clone());
5268
5269 let _ = tmux
5271 .command()
5272 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
5273 .output()
5274 .await;
5275 let _ = tmux
5276 .command()
5277 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
5278 .output()
5279 .await;
5280
5281 for _ in 0..20 {
5283 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5284 if !tmux.session_is_running(&session_name).await {
5285 break;
5286 }
5287 }
5288
5289 let task_key = task_id.to_string();
5291 capture
5292 .register_session("owner/repo", &task_key, &session_name)
5293 .await;
5294
5295 let config = EngineConfig {
5297 no_session_stuck_timeout: 600,
5298 stuck_timeout: 1800,
5299 ..EngineConfig::default()
5300 };
5301
5302 tick_check_session_completions(&tmux, "owner/repo", &capture, &store)
5303 .await
5304 .unwrap();
5305
5306 {
5312 let task_state = store.get(id).await.unwrap();
5313 if task_state.updated_at.starts_with("2020") {
5314 eprintln!(
5315 "Skipping test: tick_check_session_completions did not update stored \
5316 updated_at — session was not in snapshot (CI tmux environment issue)"
5317 );
5318 return;
5319 }
5320 }
5321
5322 tick_recover_stuck_tasks(
5324 &backend,
5325 &tmux,
5326 "owner/repo",
5327 &task_manager,
5328 &config,
5329 &store,
5330 &std::collections::HashMap::new(),
5331 &Arc::new(DashMap::new()),
5332 )
5333 .await
5334 .unwrap();
5335
5336 let task = store.get(id).await.unwrap();
5338 assert_eq!(
5339 task.status,
5340 crate::store::TaskStatus::InProgress,
5341 "task should not be reclaimed to New"
5342 );
5343 }
5344
5345 #[tokio::test]
5355 async fn session_completion_matches_repo_configured_with_git_suffix() {
5356 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5357 let tmux = Arc::new(TmuxManager::new());
5358 let transport = Arc::new(Transport::new());
5359 let capture = Arc::new(CaptureService::new(transport));
5360
5361 let repo = "owner/repo.git";
5362 let id = store
5363 .upsert_external(&crate::store::UpsertExternal {
5364 repo,
5365 ext_id: "201",
5366 title: "Git-suffix repo task",
5367 body: "",
5368 author: "",
5369 url: "",
5370 labels: &[],
5371 origin: "github",
5372 })
5373 .await
5374 .unwrap();
5375 store
5376 .update_status(id, crate::store::TaskStatus::InProgress)
5377 .await
5378 .unwrap();
5379 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
5380 .bind(id)
5381 .execute(store.pool())
5382 .await
5383 .unwrap();
5384
5385 let task_id = "201";
5388 let session_name = tmux.session_name(repo, task_id);
5389 let create_result = tmux
5390 .command()
5391 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
5392 .output()
5393 .await;
5394 match create_result {
5395 Ok(o) if o.status.success() => {}
5396 _ => {
5397 eprintln!("Skipping test: tmux not available or failed to create test session");
5398 return;
5399 }
5400 }
5401 let _guard = SessionGuard(session_name.clone());
5402
5403 let _ = tmux
5404 .command()
5405 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
5406 .output()
5407 .await;
5408 let _ = tmux
5409 .command()
5410 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
5411 .output()
5412 .await;
5413
5414 for _ in 0..20 {
5415 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5416 if !tmux.session_is_running(&session_name).await {
5417 break;
5418 }
5419 }
5420
5421 capture.register_session(repo, task_id, &session_name).await;
5422
5423 tick_check_session_completions(&tmux, repo, &capture, &store)
5424 .await
5425 .unwrap();
5426
5427 let task_state = store.get(id).await.unwrap();
5428 if task_state.updated_at.starts_with("2020") {
5429 eprintln!(
5430 "Skipping test: tick_check_session_completions did not update stored \
5431 updated_at — session was not in snapshot (CI tmux environment issue)"
5432 );
5433 return;
5434 }
5435 assert!(
5436 !task_state.updated_at.starts_with("2020"),
5437 "tick_check_session_completions must match its own repo's live sessions \
5438 even when the repo is configured with a \".git\" suffix"
5439 );
5440 }
5441
5442 #[tokio::test]
5448 async fn job_scheduler_returns_ok_on_duplicate_job_config() {
5449 let tmp = tempfile::tempdir().unwrap();
5450 let cfg = tmp.path().join(".orch.yml");
5451 std::fs::write(
5452 &cfg,
5453 "jobs:\n - id: trading-scan\n schedule: \"0 9 * * *\"\n task:\n title: Scan\n body: do it\n",
5454 )
5455 .unwrap();
5456 let jobs_dir = tmp.path().join("prompts").join("jobs");
5457 std::fs::create_dir_all(&jobs_dir).unwrap();
5458 std::fs::write(
5459 jobs_dir.join("trading-scan.md"),
5460 "---\nid: trading-scan\nschedule: '0 9 * * *'\ntitle: Trading Scan\n---\n\nDo trading scan.\n",
5461 )
5462 .unwrap();
5463
5464 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5465 let result = tick_job_scheduler(&cfg, &backend, None, "test/repo", None).await;
5466 assert!(
5467 result.is_ok(),
5468 "duplicate job id must not propagate as Err: {result:?}"
5469 );
5470 }
5471
5472 #[tokio::test]
5476 async fn job_scheduler_processes_valid_project_when_other_has_duplicate() {
5477 let bad_tmp = tempfile::tempdir().unwrap();
5479 let bad_cfg = bad_tmp.path().join(".orch.yml");
5480 std::fs::write(
5481 &bad_cfg,
5482 "jobs:\n - id: dup\n schedule: \"0 9 * * *\"\n task:\n title: Dup\n body: x\n",
5483 )
5484 .unwrap();
5485 let bad_jobs_dir = bad_tmp.path().join("prompts").join("jobs");
5486 std::fs::create_dir_all(&bad_jobs_dir).unwrap();
5487 std::fs::write(
5488 bad_jobs_dir.join("dup.md"),
5489 "---\nid: dup\nschedule: '0 9 * * *'\ntitle: Dup\n---\n\nbody\n",
5490 )
5491 .unwrap();
5492
5493 let good_tmp = tempfile::tempdir().unwrap();
5495 let good_cfg = good_tmp.path().join(".orch.yml");
5496 std::fs::write(
5497 &good_cfg,
5498 "jobs:\n - id: valid-job\n schedule: \"0 9 * * *\"\n task:\n title: Valid\n body: ok\n",
5499 )
5500 .unwrap();
5501
5502 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5503
5504 let bad_result = tick_job_scheduler(&bad_cfg, &backend, None, "test/bad-repo", None).await;
5505 assert!(
5506 bad_result.is_ok(),
5507 "bad project must not abort scheduler: {bad_result:?}"
5508 );
5509
5510 let good_result =
5511 tick_job_scheduler(&good_cfg, &backend, None, "test/good-repo", None).await;
5512 assert!(
5513 good_result.is_ok(),
5514 "valid project must still run after bad project: {good_result:?}"
5515 );
5516 }
5517
5518 #[serial_test::serial(cooldown_state)]
5526 #[tokio::test]
5527 async fn global_sweep_routes_inactive_repo_tasks() {
5528 use crate::engine::router::{Router, RouterConfig};
5529 use crate::store::{TaskStatus, UpsertExternal};
5530
5531 crate::engine::cooldown::reset_global_state().await;
5532
5533 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5534
5535 let active_id = store
5537 .upsert_external(&UpsertExternal {
5538 repo: "active/repo",
5539 ext_id: "1",
5540 title: "Active task",
5541 body: "",
5542 author: "user",
5543 url: "https://github.com/active/repo/issues/1",
5544 labels: &[],
5545 origin: "github",
5546 })
5547 .await
5548 .unwrap();
5549
5550 let inactive_id = store
5552 .upsert_external(&UpsertExternal {
5553 repo: "inactive/repo",
5554 ext_id: "42",
5555 title: "Orphaned task",
5556 body: "",
5557 author: "user",
5558 url: "https://github.com/inactive/repo/issues/42",
5559 labels: &[],
5560 origin: "github",
5561 })
5562 .await
5563 .unwrap();
5564
5565 assert_eq!(store.get(active_id).await.unwrap().status, TaskStatus::New);
5566 assert_eq!(
5567 store.get(inactive_id).await.unwrap().status,
5568 TaskStatus::New
5569 );
5570
5571 let mut model_map: std::collections::HashMap<
5572 String,
5573 std::collections::HashMap<String, Vec<String>>,
5574 > = std::collections::HashMap::new();
5575 let mut tier = std::collections::HashMap::new();
5576 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5577 model_map.insert("medium".to_string(), tier);
5578 let config = RouterConfig {
5579 mode: "round_robin".to_string(),
5580 agents: vec!["claude".to_string()],
5581 model_map,
5582 ..Default::default()
5583 };
5584 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5585
5586 let active_repos: std::collections::HashSet<String> =
5587 ["active/repo".to_string()].into_iter().collect();
5588 let mut routing_quota = RoutingTickQuota::new(1);
5589 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5590 .await
5591 .unwrap();
5592
5593 assert_eq!(
5595 store.get(active_id).await.unwrap().status,
5596 TaskStatus::New,
5597 "global sweep must not touch tasks for the active repo"
5598 );
5599
5600 let after = store.get(inactive_id).await.unwrap();
5602 assert_eq!(
5603 after.status,
5604 TaskStatus::Routed,
5605 "global sweep must route tasks from inactive repos"
5606 );
5607 assert!(
5608 matches!(&after.agent, Some(a) if !a.is_empty()),
5609 "agent must be set after routing"
5610 );
5611 }
5612
5613 #[serial_test::serial(cooldown_state)]
5621 #[tokio::test]
5622 async fn global_sweep_does_not_touch_other_active_repo_tasks() {
5623 use crate::engine::router::{Router, RouterConfig};
5624 use crate::store::{TaskStatus, UpsertExternal};
5625
5626 crate::engine::cooldown::reset_global_state().await;
5627
5628 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5629
5630 let other_active_id = store
5633 .upsert_external(&UpsertExternal {
5634 repo: "other-active/repo",
5635 ext_id: "7",
5636 title: "Other active project's task",
5637 body: "",
5638 author: "user",
5639 url: "https://github.com/other-active/repo/issues/7",
5640 labels: &[],
5641 origin: "github",
5642 })
5643 .await
5644 .unwrap();
5645
5646 let inactive_id = store
5648 .upsert_external(&UpsertExternal {
5649 repo: "inactive/repo",
5650 ext_id: "42",
5651 title: "Orphaned task",
5652 body: "",
5653 author: "user",
5654 url: "https://github.com/inactive/repo/issues/42",
5655 labels: &[],
5656 origin: "github",
5657 })
5658 .await
5659 .unwrap();
5660
5661 let mut model_map: std::collections::HashMap<
5662 String,
5663 std::collections::HashMap<String, Vec<String>>,
5664 > = std::collections::HashMap::new();
5665 let mut tier = std::collections::HashMap::new();
5666 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5667 model_map.insert("medium".to_string(), tier);
5668 let config = RouterConfig {
5669 mode: "round_robin".to_string(),
5670 agents: vec!["claude".to_string()],
5671 model_map,
5672 ..Default::default()
5673 };
5674 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5675
5676 let active_repos: std::collections::HashSet<String> =
5680 ["active/repo".to_string(), "other-active/repo".to_string()]
5681 .into_iter()
5682 .collect();
5683 let mut routing_quota = RoutingTickQuota::new(1);
5684 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5685 .await
5686 .unwrap();
5687
5688 assert_eq!(
5689 store.get(other_active_id).await.unwrap().status,
5690 TaskStatus::New,
5691 "global sweep run from a different active repo's tick must not route \
5692 another active repo's new tasks — that repo's own tick will route them"
5693 );
5694 assert_eq!(
5695 store.get(inactive_id).await.unwrap().status,
5696 TaskStatus::Routed,
5697 "global sweep must still route tasks from genuinely orphaned repos"
5698 );
5699 }
5700
5701 #[serial_test::serial(cooldown_state)]
5703 #[tokio::test]
5704 async fn global_sweep_skips_no_agent_tasks() {
5705 use crate::engine::router::{Router, RouterConfig};
5706 use crate::store::{TaskStatus, UpsertExternal};
5707
5708 crate::engine::cooldown::reset_global_state().await;
5709
5710 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5711
5712 let id = store
5713 .upsert_external(&UpsertExternal {
5714 repo: "inactive/repo",
5715 ext_id: "99",
5716 title: "No-agent task",
5717 body: "",
5718 author: "user",
5719 url: "https://github.com/inactive/repo/issues/99",
5720 labels: &["no-agent".to_string()],
5721 origin: "github",
5722 })
5723 .await
5724 .unwrap();
5725
5726 let mut model_map: std::collections::HashMap<
5727 String,
5728 std::collections::HashMap<String, Vec<String>>,
5729 > = std::collections::HashMap::new();
5730 let mut tier = std::collections::HashMap::new();
5731 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5732 model_map.insert("medium".to_string(), tier);
5733 let config = RouterConfig {
5734 mode: "round_robin".to_string(),
5735 agents: vec!["claude".to_string()],
5736 model_map,
5737 ..Default::default()
5738 };
5739 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5740
5741 let active_repos: std::collections::HashSet<String> =
5742 ["active/repo".to_string()].into_iter().collect();
5743 let mut routing_quota = RoutingTickQuota::new(1);
5744 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5745 .await
5746 .unwrap();
5747
5748 assert_eq!(
5749 store.get(id).await.unwrap().status,
5750 TaskStatus::New,
5751 "no-agent tasks must not be routed"
5752 );
5753 }
5754
5755 #[serial_test::serial(cooldown_state)]
5760 #[tokio::test]
5761 async fn global_sweep_honors_shared_routing_quota() {
5762 use crate::engine::router::{Router, RouterConfig};
5763 use crate::store::{TaskStatus, UpsertExternal};
5764
5765 crate::engine::cooldown::reset_global_state().await;
5766
5767 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5768 for ext_id in ["1", "2"] {
5769 store
5770 .upsert_external(&UpsertExternal {
5771 repo: "inactive/repo",
5772 ext_id,
5773 title: "Orphaned task",
5774 body: "",
5775 author: "user",
5776 url: &format!("https://github.com/inactive/repo/issues/{ext_id}"),
5777 labels: &[],
5778 origin: "github",
5779 })
5780 .await
5781 .unwrap();
5782 }
5783
5784 let mut model_map: std::collections::HashMap<
5785 String,
5786 std::collections::HashMap<String, Vec<String>>,
5787 > = std::collections::HashMap::new();
5788 let mut tier = std::collections::HashMap::new();
5789 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5790 model_map.insert("medium".to_string(), tier);
5791 let config = RouterConfig {
5792 mode: "round_robin".to_string(),
5793 agents: vec!["claude".to_string()],
5794 model_map,
5795 ..Default::default()
5796 };
5797 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5798
5799 let active_repos: std::collections::HashSet<String> =
5800 ["active/repo".to_string()].into_iter().collect();
5801 let mut routing_quota = RoutingTickQuota::new(1);
5802 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5803 .await
5804 .unwrap();
5805
5806 let all_tasks = store.list_all_global().await.unwrap();
5807 let routed = all_tasks
5808 .iter()
5809 .filter(|task| task.status == TaskStatus::Routed)
5810 .count();
5811 let still_new = all_tasks
5812 .iter()
5813 .filter(|task| task.status == TaskStatus::New)
5814 .count();
5815
5816 assert_eq!(routed, 1, "only one task may consume the shared quota");
5817 assert_eq!(still_new, 1, "remaining tasks wait for the next tick");
5818
5819 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5820 .await
5821 .unwrap();
5822 let after_exhausted = store.list_all_global().await.unwrap();
5823 assert_eq!(
5824 after_exhausted
5825 .iter()
5826 .filter(|task| task.status == TaskStatus::Routed)
5827 .count(),
5828 1,
5829 "an exhausted shared quota must not reset within the same tick"
5830 );
5831 }
5832
5833 #[tokio::test]
5850 async fn group_orphaned_routed_tasks_selects_inactive_repo_tasks() {
5851 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
5852
5853 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5854
5855 async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
5856 let id = store
5857 .upsert_external(&UpsertExternal {
5858 repo,
5859 ext_id,
5860 title: "Routed task",
5861 body: "",
5862 author: "user",
5863 url: &format!("https://github.com/{repo}/issues/{ext_id}"),
5864 labels: &[],
5865 origin: "github",
5866 })
5867 .await
5868 .unwrap();
5869 store
5870 .store_route(&StoreRoute {
5871 id,
5872 agent: "claude",
5873 model: Some("sonnet"),
5874 complexity: "medium",
5875 estimate: 0,
5876 reason: "test",
5877 profile: "{}",
5878 skills: "[]",
5879 })
5880 .await
5881 .unwrap();
5882 store.update_status(id, TaskStatus::Routed).await.unwrap();
5883 id
5884 }
5885
5886 let active_id = make_routed(&store, "active/repo", "1").await;
5887 let inactive_id_a = make_routed(&store, "inactive/repo", "2").await;
5888 let other_inactive_id = make_routed(&store, "other-inactive/repo", "3").await;
5889 let inactive_id_b = make_routed(&store, "inactive/repo", "4").await;
5890
5891 let all_routed = store
5892 .list_all_by_status_global(TaskStatus::Routed)
5893 .await
5894 .unwrap();
5895 let active_repos: std::collections::HashSet<String> =
5896 ["active/repo".to_string()].into_iter().collect();
5897 let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
5898
5899 assert!(
5900 !by_repo.contains_key("active/repo"),
5901 "global sweep must not select tasks for the active repo"
5902 );
5903 let inactive_ids: std::collections::HashSet<i64> = by_repo
5904 .get("inactive/repo")
5905 .expect("inactive/repo must be selected")
5906 .iter()
5907 .map(|t| t.id)
5908 .collect();
5909 assert_eq!(
5910 inactive_ids,
5911 [inactive_id_a, inactive_id_b].into_iter().collect(),
5912 "global sweep must group all orphaned tasks for a repo together"
5913 );
5914 assert_eq!(
5915 by_repo
5916 .get("other-inactive/repo")
5917 .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
5918 Some(vec![other_inactive_id])
5919 );
5920
5921 assert_eq!(
5924 store.get(active_id).await.unwrap().status,
5925 TaskStatus::Routed
5926 );
5927 }
5928
5929 #[tokio::test]
5934 async fn group_orphaned_routed_tasks_excludes_all_active_repos_not_just_current() {
5935 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
5936
5937 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5938
5939 async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
5940 let id = store
5941 .upsert_external(&UpsertExternal {
5942 repo,
5943 ext_id,
5944 title: "Routed task",
5945 body: "",
5946 author: "user",
5947 url: &format!("https://github.com/{repo}/issues/{ext_id}"),
5948 labels: &[],
5949 origin: "github",
5950 })
5951 .await
5952 .unwrap();
5953 store
5954 .store_route(&StoreRoute {
5955 id,
5956 agent: "claude",
5957 model: Some("sonnet"),
5958 complexity: "medium",
5959 estimate: 0,
5960 reason: "test",
5961 profile: "{}",
5962 skills: "[]",
5963 })
5964 .await
5965 .unwrap();
5966 store.update_status(id, TaskStatus::Routed).await.unwrap();
5967 id
5968 }
5969
5970 let repo_a_id = make_routed(&store, "active/repo-a", "1").await;
5971 let repo_b_id = make_routed(&store, "active/repo-b", "2").await;
5972 let orphaned_id = make_routed(&store, "inactive/repo", "3").await;
5973
5974 let all_routed = store
5975 .list_all_by_status_global(TaskStatus::Routed)
5976 .await
5977 .unwrap();
5978 let active_repos: std::collections::HashSet<String> =
5981 ["active/repo-a".to_string(), "active/repo-b".to_string()]
5982 .into_iter()
5983 .collect();
5984 let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
5985
5986 assert!(
5987 !by_repo.contains_key("active/repo-a"),
5988 "the current repo's own tasks must not be swept"
5989 );
5990 assert!(
5991 !by_repo.contains_key("active/repo-b"),
5992 "another active repo's tasks must not be swept during repo A's tick"
5993 );
5994 assert_eq!(
5995 by_repo
5996 .get("inactive/repo")
5997 .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
5998 Some(vec![orphaned_id]),
5999 "a genuinely inactive repo's tasks must still be swept"
6000 );
6001
6002 assert_eq!(
6004 store.get(repo_a_id).await.unwrap().status,
6005 TaskStatus::Routed
6006 );
6007 assert_eq!(
6008 store.get(repo_b_id).await.unwrap().status,
6009 TaskStatus::Routed
6010 );
6011 }
6012
6013 #[tokio::test]
6015 async fn global_dispatch_sweep_skips_no_agent_tasks() {
6016 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
6017
6018 let store = Arc::new(TaskStore::open_memory().await.unwrap());
6019
6020 let id = store
6021 .upsert_external(&UpsertExternal {
6022 repo: "inactive/repo",
6023 ext_id: "99",
6024 title: "No-agent routed task",
6025 body: "",
6026 author: "user",
6027 url: "https://github.com/inactive/repo/issues/99",
6028 labels: &["no-agent".to_string()],
6029 origin: "github",
6030 })
6031 .await
6032 .unwrap();
6033 store
6034 .store_route(&StoreRoute {
6035 id,
6036 agent: "claude",
6037 model: Some("sonnet"),
6038 complexity: "medium",
6039 estimate: 0,
6040 reason: "test",
6041 profile: "{}",
6042 skills: "[]",
6043 })
6044 .await
6045 .unwrap();
6046 store.update_status(id, TaskStatus::Routed).await.unwrap();
6047
6048 let tmux = Arc::new(TmuxManager::new());
6053 let transport = Arc::new(Transport::new());
6054 let capture = Arc::new(CaptureService::new(transport));
6055 let semaphore = Arc::new(Semaphore::new(4));
6056 let (weight_tx, _weight_rx) = mpsc::channel(16);
6057 let dispatching = Arc::new(DashMap::new());
6058 let dispatch_mode = DispatchMode {
6059 is_degraded: false,
6060 healthy_agents: 1,
6061 threshold: 1,
6062 };
6063
6064 let active_repos: std::collections::HashSet<String> =
6065 ["active/repo".to_string()].into_iter().collect();
6066 dispatch_routed_tasks_global(
6067 &tmux,
6068 &capture,
6069 &semaphore,
6070 &weight_tx,
6071 dispatch_mode,
6072 &dispatching,
6073 &store,
6074 &std::collections::HashMap::new(),
6075 &active_repos,
6076 )
6077 .await
6078 .unwrap();
6079
6080 assert_eq!(
6081 store.get(id).await.unwrap().status,
6082 TaskStatus::Routed,
6083 "no-agent tasks must not be dispatched"
6084 );
6085 }
6086}