1use crate::backends::{ExternalBackend, ExternalId, ExternalTask, Status};
13use crate::channels::capture::CaptureService;
14use crate::config;
15use crate::engine::cooldown::{
16 github_circuit_remaining_secs, is_agent_in_cooldown, is_github_circuit_open,
17 is_model_in_cooldown, record_agent_failure_with_message, record_silence_detection,
18 set_agent_cooldown, set_model_cooldown, SILENCE_AGENT_COOLDOWN_SECS,
19 SILENCE_EXTENDED_COOLDOWN_SECS,
20};
21use crate::engine::dispatch_guard::DispatchGuard;
22use crate::engine::jobs;
23use crate::engine::router::{get_route_result, AllAgentsCooledError, RouteResultError, Router};
24use crate::engine::runner::{TaskRunner, WeightSignal};
25use crate::engine::tasks::{is_internal_id, TaskManager};
26use crate::repo_context::REPO_CONTEXT;
27use crate::store::TaskStore;
28use crate::tmux::TmuxManager;
29use dashmap::{DashMap, DashSet};
30use std::sync::atomic::{AtomicBool, Ordering};
31use std::sync::Arc;
32use std::sync::LazyLock;
33use std::time::{Instant, SystemTime};
34use tokio::sync::{mpsc, Semaphore};
35
36static ENGINE_START_TIME: LazyLock<u64> = LazyLock::new(|| {
39 SystemTime::now()
40 .duration_since(SystemTime::UNIX_EPOCH)
41 .unwrap_or_default()
42 .as_secs()
43});
44
45static STARTUP_CLEANUP_DONE: AtomicBool = AtomicBool::new(false);
47
48pub(crate) struct RoutingTickQuota {
55 remaining: usize,
56}
57
58impl RoutingTickQuota {
59 pub(crate) fn new(max: usize) -> Self {
60 Self { remaining: max }
61 }
62
63 fn is_exhausted(&self) -> bool {
64 self.remaining == 0
65 }
66
67 fn remaining(&self) -> usize {
68 self.remaining
69 }
70
71 fn consume_one(&mut self) -> bool {
72 if self.remaining == 0 {
73 return false;
74 }
75 self.remaining -= 1;
76 true
77 }
78}
79
80async fn startup_cleanup(tmux: &TmuxManager) {
83 if STARTUP_CLEANUP_DONE
85 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
86 .is_err()
87 {
88 return;
89 }
90
91 let start_time = *ENGINE_START_TIME;
92 tracing::info!(
93 start_time,
94 "running startup cleanup for stale tmux sessions"
95 );
96
97 match tmux.kill_stale_sessions(start_time).await {
98 Ok(killed) => {
99 if killed > 0 {
100 tracing::info!(killed, "startup cleanup killed stale sessions");
101 } else {
102 tracing::debug!("no stale tmux sessions found on startup");
103 }
104 }
105 Err(e) => {
106 tracing::warn!(error = %e, "failed to run startup cleanup");
107 }
108 }
109}
110
111use super::EngineConfig;
112use crate::store::{set_review_session_expected, store_set_by_id, store_touch_updated_at};
113
114async fn recover_routed_blocked_dispatch(
115 backend: &Arc<dyn ExternalBackend>,
116 store: &Arc<TaskStore>,
117 task_manager: &Arc<TaskManager>,
118 repo: &str,
119 task: &ExternalTask,
120 timing: &StuckTaskTiming,
121) {
122 tracing::warn!(
123 task_id = task.id.0,
124 age_mins = timing.age.num_minutes(),
125 threshold_mins = timing.threshold / 60,
126 "recovering routed task: dispatch blocked by stale tmux session → new"
127 );
128
129 let resolved_store_id = match store.resolve_task_id(repo, &task.id.0).await {
130 Ok(Some(id)) => Some(id),
131 Ok(None) => {
132 tracing::warn!(
133 task_id = %task.id.0,
134 repo,
135 "resolve_task_id returned None during routed-task recovery"
136 );
137 None
138 }
139 Err(e) => {
140 tracing::error!(
141 task_id = %task.id.0,
142 repo,
143 error = %e,
144 "resolve_task_id failed during routed-task recovery"
145 );
146 None
147 }
148 };
149
150 if let Some(store_id) = resolved_store_id {
151 if let Err(e) = store_set_by_id(
152 &Some(store),
153 store_id,
154 &[
155 ("agent", serde_json::Value::Null),
156 ("model", serde_json::Value::Null),
157 ("route_attempts", serde_json::json!(0)),
158 ],
159 )
160 .await
161 {
162 tracing::warn!(
163 task_id = task.id.0,
164 ?e,
165 "failed to clear routing fields for routed stuck task — will retry next tick"
166 );
167 return;
168 }
169 finalize_open_runs_for_recovery(
170 store,
171 store_id,
172 "stuck-task recovery: stale session blocking dispatch killed",
173 )
174 .await;
175 }
176
177 if let Err(e) = task_manager.update_task_status(&task.id, Status::New).await {
178 tracing::warn!(
179 task_id = task.id.0,
180 ?e,
181 "failed to reset routed stuck task status"
182 );
183 return;
184 }
185
186 crate::store::store_log_activity(
187 &Some(Arc::clone(store)),
188 repo,
189 &task.id.0,
190 "rerouted",
191 Some("routed"),
192 Some("new"),
193 None::<&str>,
194 None::<&str>,
195 Some(&serde_json::json!({
196 "failure_reason": "routed_blocked_by_existing_session",
197 "age_minutes": timing.age.num_minutes(),
198 "had_session": true,
199 })),
200 )
201 .await;
202
203 if !is_internal_id(&task.id.0) {
204 let backend_clone = Arc::clone(backend);
205 let task_id_clone = task.id.clone();
206 let body = format!(
207 "[{}] recovered: stuck routed task — existing tmux session blocked dispatch for {}m; cleared route for retry{}",
208 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
209 timing.age.num_minutes(),
210 crate::engine::orch_footer()
211 );
212 tokio::spawn(async move {
213 if let Err(e) = backend_clone.post_comment(&task_id_clone, &body).await {
214 tracing::warn!(
215 task_id = task_id_clone.0,
216 ?e,
217 "failed to post routed-task recovery comment"
218 );
219 }
220 });
221 }
222}
223
224pub(crate) async fn tick_check_session_completions(
226 tmux: &Arc<TmuxManager>,
227 repo: &str,
228 capture: &Arc<CaptureService>,
229 store: &Arc<TaskStore>,
230) -> anyhow::Result<()> {
231 let _span = tracing::info_span!("engine.tick.phase1.sessions").entered();
232 let session_snapshot = tmux.snapshot().await;
233 let repo_name = repo
239 .rsplit('/')
240 .next()
241 .unwrap_or(repo)
242 .trim_end_matches(".git");
243 for (session, active) in session_snapshot {
244 if session.project != repo_name {
246 continue;
247 }
248
249 if !active {
250 tracing::info!(
251 session = %session.name,
252 task_id = %session.task_id,
253 "session completed, collecting results"
254 );
255 let store_task_id = {
266 let base = session
267 .task_id
268 .strip_suffix("-review")
269 .unwrap_or(&session.task_id);
270 if let Some(n) = base.strip_prefix("internal-") {
271 format!("internal:{n}")
272 } else {
273 base.to_string()
274 }
275 };
276 store_touch_updated_at(&Some(Arc::clone(store)), repo, &store_task_id).await;
277 capture.unregister_session(repo, &session.task_id).await;
280 if let Err(e) = tmux.kill_session(&session.name).await {
282 tracing::debug!(
283 session = %session.name,
284 ?e,
285 "kill_session failed (session may already be gone)"
286 );
287 }
288 }
289 }
290 Ok(())
291}
292
293#[derive(Debug)]
294struct StuckTaskTiming {
295 has_session: bool,
296 age: chrono::Duration,
297 threshold: u64,
298}
299
300#[allow(clippy::too_many_arguments)]
301fn stuck_task_timing_from_map(
302 tmux: &Arc<TmuxManager>,
303 repo: &str,
304 session_task_id: &str,
305 updated_at: &str,
306 config: &EngineConfig,
307 parse_error_message: &'static str,
308 session_map: &std::collections::HashMap<String, bool>,
309) -> Option<StuckTaskTiming> {
310 let session_name = tmux.session_name(repo, session_task_id);
311 let has_session = session_map.get(&session_name).copied().unwrap_or(false);
313 let threshold = if has_session {
314 config.stuck_timeout
315 } else {
316 config.no_session_stuck_timeout
317 };
318
319 let updated = match chrono::DateTime::parse_from_rfc3339(updated_at) {
320 Ok(dt) => dt.with_timezone(&chrono::Utc),
321 Err(e) => {
322 tracing::warn!(
323 task_id = session_task_id,
324 updated_at,
325 ?e,
326 error_message = parse_error_message,
327 "cannot parse updated_at"
328 );
329 return None;
330 }
331 };
332
333 let raw_age = chrono::Utc::now() - updated;
334 let suspended = super::suspend::suspended_duration_since(updated);
338 let age = (raw_age - suspended).max(chrono::Duration::zero());
339 if age.num_seconds() <= threshold as i64 {
340 return None;
341 }
342
343 Some(StuckTaskTiming {
344 has_session,
345 age,
346 threshold,
347 })
348}
349
350async fn in_review_stuck_timestamp(store: &TaskStore, store_id: i64, fallback: &str) -> String {
352 match store.in_review_entry(store_id).await {
353 Ok(Some((entered_at, _))) => entered_at,
354 Ok(None) => fallback.to_string(),
355 Err(e) => {
356 tracing::warn!(
357 store_id,
358 error = %e,
359 "failed to read in_review entry, falling back to updated_at"
360 );
361 fallback.to_string()
362 }
363 }
364}
365
366async fn finalize_open_runs_for_recovery(store: &TaskStore, store_id: i64, reason: &str) {
369 if let Err(e) = store
370 .finalize_incomplete_runs(store_id, "aborted", reason)
371 .await
372 {
373 tracing::warn!(
374 store_id,
375 error = %e,
376 reason,
377 "failed to finalize incomplete runs during recovery"
378 );
379 }
380}
381
382pub(crate) async fn tick_detect_silent_agents(
391 tmux: &Arc<TmuxManager>,
392 repo: &str,
393 capture: &Arc<CaptureService>,
394 backend: &Arc<dyn ExternalBackend>,
395 task_manager: &Arc<TaskManager>,
396 config: &EngineConfig,
397 store: &Arc<TaskStore>,
398) -> anyhow::Result<()> {
399 let _span = tracing::info_span!("engine.tick.phase1b.silence").entered();
400 let grace = std::time::Duration::from_secs(config.silence_grace_period);
401 let silent_sessions = capture.get_silent_sessions_for_repo(repo, grace).await;
402
403 for (task_id, session_name, silence_age_secs) in silent_sessions {
404 let use_backend = should_use_backend(&task_id);
405 let store_task = match store.resolve_task_id(repo, &task_id).await {
407 Ok(Some(store_id)) => match store.get(store_id).await {
408 Ok(task) => Some(task),
409 Err(e) => {
410 tracing::warn!(
411 task_id,
412 repo,
413 store_id,
414 operation = "store.get",
415 error = %e,
416 "silence detection: db error fetching task — agent/model cooldown will be skipped"
417 );
418 None
419 }
420 },
421 Ok(None) => {
422 tracing::debug!(
423 task_id,
424 repo,
425 operation = "resolve_task_id",
426 "silence detection: task not found in store (may have been cleaned up)"
427 );
428 None
429 }
430 Err(e) => {
431 tracing::warn!(
432 task_id,
433 repo,
434 operation = "resolve_task_id",
435 error = %e,
436 "silence detection: db error resolving task id — agent/model cooldown will be skipped"
437 );
438 None
439 }
440 };
441 let agent_name = store_task
442 .as_ref()
443 .and_then(|t| t.agent.clone())
444 .unwrap_or_default();
445 let model_name = store_task
446 .as_ref()
447 .and_then(|t| t.model.clone())
448 .unwrap_or_default();
449
450 tracing::warn!(
451 task_id,
452 agent = %agent_name,
453 model = %model_name,
454 silence_age_secs,
455 grace_secs = config.silence_grace_period,
456 cooldown_secs = config.silence_cooldown,
457 "agent silent since session start — killing session, cooling down model + agent, failing over"
458 );
459
460 if let Err(e) = tmux.kill_session(&session_name).await {
462 tracing::debug!(
463 task_id,
464 ?e,
465 "kill_session failed for silent agent (may already be gone)"
466 );
467 }
468
469 capture.unregister_session(repo, &task_id).await;
471
472 if let Some(store_id) = store_task.as_ref().map(|t| t.id) {
474 finalize_open_runs_for_recovery(store, store_id, "silence detection: session killed")
475 .await;
476 }
477
478 let mut extended_note = String::new();
479
480 if !agent_name.is_empty() && !model_name.is_empty() {
482 set_model_cooldown(
483 &agent_name,
484 &model_name,
485 config.silence_cooldown,
486 "silence_detected",
487 )
488 .await;
489 if let Some(result) = record_silence_detection(&agent_name, &model_name).await {
490 if result.extended_cooldown_applied {
491 extended_note = format!(
492 " ({} silences in 24h -> extended cooldown {}s)",
493 result.count, SILENCE_EXTENDED_COOLDOWN_SECS
494 );
495 }
496 }
497 }
498
499 if !agent_name.is_empty() {
503 set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
504 }
505
506 let task_eid = ExternalId(task_id.clone());
511
512 let available: Vec<String> = crate::engine::configured_agents()
514 .into_iter()
515 .filter(|a| crate::cmd_cache::command_exists(a))
516 .collect();
517 let chain = crate::engine::runner::response::get_reroute_chain(
518 &task_id,
519 &Some(Arc::clone(store)),
520 repo,
521 )
522 .await;
523 let chain = crate::engine::runner::response::update_reroute_chain(
524 &task_id,
525 &agent_name,
526 &chain,
527 &Some(Arc::clone(store)),
528 repo,
529 )
530 .await;
531
532 let (next_status, next_agent) = if let Some(fallback) =
533 crate::engine::runner::response::pick_fallback_agent(&agent_name, &chain, &available)
534 {
535 tracing::info!(
536 task_id,
537 from = %agent_name,
538 to = %fallback,
539 "silence detection: failover to different agent, setting routed"
540 );
541 (Status::Routed, Some(fallback))
542 } else {
543 tracing::warn!(
544 task_id,
545 "silence detection: no fallback agents available, resetting to new for re-routing"
546 );
547 (Status::New, None)
548 };
549
550 if use_backend {
551 if let Some(ref st) = store_task {
552 let stale_labels: Vec<String> = st
554 .labels
555 .iter()
556 .filter(|l| {
557 l.starts_with("agent:")
558 || l.starts_with("complexity:")
559 || l.starts_with("model:")
560 })
561 .cloned()
562 .collect();
563 if !stale_labels.is_empty() {
564 let backend_clone = Arc::clone(backend);
565 let task_eid_clone = task_eid.clone();
566 tokio::spawn(async move {
567 for label in &stale_labels {
568 if let Err(e) = backend_clone.remove_label(&task_eid_clone, label).await
569 {
570 tracing::warn!(task_id = task_eid_clone.0, label, error = %e, "failed to remove label during silence detection re-route");
571 }
572 }
573 });
574 }
575 }
576 }
577
578 if let Some(store_id) = store_task.as_ref().map(|t| t.id) {
579 if let Some(ref fallback) = next_agent {
580 let _ = store_set_by_id(
584 &Some(store),
585 store_id,
586 &[
587 ("agent", serde_json::json!(fallback)),
588 ("model", serde_json::json!("")),
589 (
590 "last_error",
591 serde_json::json!(format!(
592 "silence detected after {}s, failing over to {}",
593 config.silence_grace_period, fallback
594 )),
595 ),
596 ],
597 )
598 .await;
599 } else {
600 let _ = store_set_by_id(
601 &Some(store),
602 store_id,
603 &[
604 ("agent", serde_json::Value::Null),
605 ("model", serde_json::Value::Null),
606 (
607 "last_error",
608 serde_json::json!(format!(
609 "silence detected after {}s, no fallback agents available",
610 config.silence_grace_period
611 )),
612 ),
613 ],
614 )
615 .await;
616 }
617 }
618
619 if let Err(e) = task_manager
620 .update_task_status(&task_eid, next_status)
621 .await
622 {
623 tracing::warn!(task_id, ?e, "failed to update silent task status");
624 continue;
625 }
626
627 let details = serde_json::json!({
629 "failure_reason": "agent_silence",
630 "silence_duration_secs": config.silence_grace_period,
631 "cooldown_applied": !agent_name.is_empty() && !model_name.is_empty(),
632 "fallback_available": next_agent.is_some(),
633 });
634 crate::store::store_log_activity(
635 &Some(Arc::clone(store)),
636 repo,
637 &task_id,
638 "rerouted",
639 Some("in_progress"),
640 Some(next_status.as_str()),
641 next_agent.as_deref(),
642 None,
643 Some(&details),
644 )
645 .await;
646
647 let action = if let Some(ref fallback) = next_agent {
649 format!("failing over to {fallback}")
650 } else {
651 "marking needs_review (no fallback agents)".to_string()
652 };
653 let comment = format!(
654 "[{}] agent silent for {}s since session start — killed session, cooled down model `{}:{}` for {}s{}, agent `{}` for {}s, {}{}",
655 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
656 config.silence_grace_period,
657 agent_name,
658 model_name,
659 config.silence_cooldown,
660 extended_note,
661 agent_name,
662 SILENCE_AGENT_COOLDOWN_SECS,
663 action,
664 crate::engine::orch_footer(),
665 );
666 if use_backend {
667 let backend_clone = Arc::clone(backend);
669 let task_eid_clone = task_eid.clone();
670 tokio::spawn(async move {
671 if let Err(e) = backend_clone.post_comment(&task_eid_clone, &comment).await {
672 tracing::warn!(
673 task_id = task_eid_clone.0,
674 ?e,
675 "failed to post silence detection comment"
676 );
677 }
678 });
679 }
680 }
681
682 Ok(())
683}
684
685fn should_use_backend(task_id: &str) -> bool {
686 !is_internal_id(task_id)
687}
688
689#[allow(clippy::too_many_arguments)]
694pub(crate) async fn tick_recover_stuck_tasks(
695 backend: &Arc<dyn ExternalBackend>,
696 tmux: &Arc<TmuxManager>,
697 repo: &str,
698 task_manager: &Arc<TaskManager>,
699 config: &EngineConfig,
700 store: &Arc<TaskStore>,
701 session_map: &std::collections::HashMap<String, bool>,
702 router: &Router,
703 dispatching: &Arc<DashMap<String, String>>,
704 auto_merge_in_flight: &Arc<DashSet<String>>,
705) -> anyhow::Result<()> {
706 let _span = tracing::info_span!("engine.tick.phase2.stuck_tasks").entered();
707
708 let in_progress = match task_manager
709 .list_external_by_status(Status::InProgress)
710 .await
711 {
712 Ok(tasks) => tasks,
713 Err(e) => {
714 tracing::warn!(?e, "failed to list in_progress tasks for stuck recovery");
715 vec![]
716 }
717 };
718 for task in &in_progress {
719 let Some(timing) = stuck_task_timing_from_map(
720 tmux,
721 repo,
722 &task.id.0,
723 &task.updated_at,
724 config,
725 "cannot parse updated_at, skipping stuck-task check",
726 session_map,
727 ) else {
728 continue;
729 };
730
731 if !timing.has_session {
738 let dispatch_key = format!("{repo}/{}", task.id.0);
739 if dispatching.contains_key(&dispatch_key) {
740 tracing::info!(
748 task_id = task.id.0,
749 dispatch_key,
750 age_mins = timing.age.num_minutes(),
751 "skipping stuck-task reclaim: runner still actively processing this task"
752 );
753 continue;
754 }
755 }
756
757 if timing.has_session {
758 tracing::warn!(
759 task_id = task.id.0,
760 age_mins = timing.age.num_minutes(),
761 threshold_mins = timing.threshold / 60,
762 "recovering stuck task: timed out with active session → new"
763 );
764 } else {
765 tracing::warn!(
766 task_id = task.id.0,
767 dispatch_key = format!("{repo}/{}", task.id.0),
768 age_mins = timing.age.num_minutes(),
769 threshold_mins = timing.threshold / 60,
770 "recovering stuck task: no session found — reclaiming early → new"
771 );
772 }
773 let mut cached_store_id: Option<i64> = None;
778 if timing.has_session {
779 let store_task = match store.resolve_task_id(repo, &task.id.0).await {
780 Ok(Some(store_id)) => {
781 cached_store_id = Some(store_id);
782 match store.get(store_id).await {
783 Ok(t) => Some(t),
784 Err(e) => {
785 tracing::warn!(task_id = task.id.0, error = %e, "failed to fetch task from store for stuck-task cooldown");
786 None
787 }
788 }
789 }
790 Ok(None) => None,
791 Err(e) => {
792 tracing::warn!(task_id = task.id.0, error = %e, "failed to resolve task id for stuck-task cooldown");
793 None
794 }
795 };
796 let agent_name = store_task
797 .as_ref()
798 .and_then(|t| t.agent.clone())
799 .unwrap_or_default();
800 let model_name = store_task
801 .as_ref()
802 .and_then(|t| t.model.clone())
803 .unwrap_or_default();
804
805 if !agent_name.is_empty() && !model_name.is_empty() {
806 set_model_cooldown(
807 &agent_name,
808 &model_name,
809 config.silence_cooldown,
810 "silence_detected",
811 )
812 .await;
813 record_agent_failure_with_message(
814 &agent_name,
815 &format!(
816 "stuck with active session after {}m",
817 timing.age.num_minutes()
818 ),
819 )
820 .await;
821 }
822 if !agent_name.is_empty() {
823 set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
824 }
825 tracing::warn!(
826 task_id = task.id.0,
827 agent = %agent_name,
828 model = %model_name,
829 cooldown_secs = config.silence_cooldown,
830 "stuck-task cooldown applied — router will avoid this agent/model on next attempt"
831 );
832 }
833
834 {
837 let stale_labels: Vec<String> = task
838 .labels
839 .iter()
840 .filter(|l| l.starts_with("agent:") || l.starts_with("model:"))
841 .cloned()
842 .collect();
843 if !stale_labels.is_empty() {
844 let backend_clone = Arc::clone(backend);
845 let task_id_clone = task.id.clone();
846 tokio::spawn(async move {
847 for label in &stale_labels {
848 if let Err(e) = backend_clone.remove_label(&task_id_clone, label).await {
849 tracing::warn!(task_id = task_id_clone.0, label, error = %e, "failed to remove stale routing label during stuck-task recovery");
850 }
851 }
852 });
853 }
854 }
855 let resolved_store_id = match cached_store_id {
856 Some(id) => Some(id),
857 None => match store.resolve_task_id(repo, &task.id.0).await {
858 Ok(Some(id)) => Some(id),
859 Ok(None) => {
860 tracing::warn!(
861 task_id = %task.id.0,
862 repo,
863 "resolve_task_id returned None during stuck-task recovery"
864 );
865 None
866 }
867 Err(e) => {
868 tracing::error!(
869 task_id = %task.id.0,
870 repo,
871 error = %e,
872 "resolve_task_id failed during stuck-task recovery"
873 );
874 None
875 }
876 },
877 };
878 if let Some(store_id) = resolved_store_id {
879 if let Err(e) = store_set_by_id(
880 &Some(store),
881 store_id,
882 &[
883 ("agent", serde_json::Value::Null),
884 ("model", serde_json::Value::Null),
885 ("route_attempts", serde_json::json!(0)),
886 ],
887 )
888 .await
889 {
890 tracing::warn!(
891 task_id = task.id.0,
892 ?e,
893 "failed to clear routing fields for stuck task — will retry next tick"
894 );
895 continue;
896 }
897 finalize_open_runs_for_recovery(
898 store,
899 store_id,
900 if timing.has_session {
901 "stuck-task recovery: active session killed"
902 } else {
903 "stuck-task recovery: no session found"
904 },
905 )
906 .await;
907 }
908 if let Err(e) = task_manager.update_task_status(&task.id, Status::New).await {
909 tracing::warn!(task_id = task.id.0, ?e, "failed to reset stuck task status");
910 continue;
911 }
912
913 let details = serde_json::json!({
915 "failure_reason": if timing.has_session { "stuck_with_session" } else { "stuck_no_session" },
916 "age_minutes": timing.age.num_minutes(),
917 "had_session": timing.has_session,
918 });
919 crate::store::store_log_activity(
920 &Some(Arc::clone(store)),
921 repo,
922 &task.id.0,
923 "rerouted",
924 Some("in_progress"),
925 Some("new"),
926 None::<&str>,
927 None::<&str>,
928 Some(&details),
929 )
930 .await;
931
932 let reason = if timing.has_session {
933 format!(
934 "timed out after {}m with active session (cleared agent for re-routing)",
935 timing.age.num_minutes()
936 )
937 } else {
938 format!(
939 "no session found — reclaiming early after {}m (cleared agent for re-routing)",
940 timing.age.num_minutes()
941 )
942 };
943 {
945 let backend_clone = Arc::clone(backend);
946 let task_id_clone = task.id.clone();
947 let body = format!(
948 "[{}] recovered: stuck in_progress — {}{}",
949 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
950 reason,
951 crate::engine::orch_footer()
952 );
953 tokio::spawn(async move {
954 if let Err(e) = backend_clone.post_comment(&task_id_clone, &body).await {
955 tracing::warn!(
956 task_id = task_id_clone.0,
957 ?e,
958 "failed to post stuck-task recovery comment"
959 );
960 }
961 });
962 }
963 }
964
965 let mut routed = match task_manager.list_external_by_status(Status::Routed).await {
966 Ok(tasks) => tasks,
967 Err(e) => {
968 tracing::warn!(?e, "failed to list routed tasks for stuck recovery");
969 vec![]
970 }
971 };
972 match task_manager
973 .list_internal_by_status(crate::store::TaskStatus::Routed)
974 .await
975 {
976 Ok(tasks) => routed.extend(tasks),
977 Err(e) => tracing::warn!(
978 ?e,
979 "failed to list internal routed tasks for stuck recovery"
980 ),
981 }
982 for task in &routed {
983 let Some(timing) = stuck_task_timing_from_map(
984 tmux,
985 repo,
986 &task.id.0,
987 &task.updated_at,
988 config,
989 "cannot parse updated_at, skipping routed stuck-task check",
990 session_map,
991 ) else {
992 continue;
993 };
994
995 if !timing.has_session {
996 continue;
997 }
998
999 let session_name = tmux.session_name(repo, &task.id.0);
1000 if let Err(e) = tmux.kill_session(&session_name).await {
1001 tracing::warn!(
1002 task_id = task.id.0,
1003 session = %session_name,
1004 error = %e,
1005 "failed to kill stale tmux session blocking routed task"
1006 );
1007 continue;
1008 }
1009
1010 recover_routed_blocked_dispatch(backend, store, task_manager, repo, task, &timing).await;
1011 }
1012
1013 let all_external_in_progress = match store
1017 .list_all_by_status_global(crate::store::TaskStatus::InProgress)
1018 .await
1019 {
1020 Ok(tasks) => tasks,
1021 Err(e) => {
1022 tracing::warn!(
1023 ?e,
1024 "failed to list global in_progress tasks for cross-repo stuck recovery"
1025 );
1026 vec![]
1027 }
1028 };
1029 for task in all_external_in_progress
1030 .iter()
1031 .filter(|t| t.origin != "internal" && t.repo != repo)
1032 {
1033 let external_id = task
1034 .external_id
1035 .clone()
1036 .unwrap_or_else(|| task.id.to_string());
1037 let Some(timing) = stuck_task_timing_from_map(
1038 tmux,
1039 &task.repo,
1040 &external_id,
1041 &task.updated_at,
1042 config,
1043 "cannot parse updated_at, skipping cross-repo stuck-task check",
1044 session_map,
1045 ) else {
1046 continue;
1047 };
1048
1049 if !timing.has_session {
1053 let dispatch_key = format!("{}/{external_id}", task.repo);
1054 if dispatching.contains_key(&dispatch_key) {
1055 tracing::info!(
1059 task_id = task.id,
1060 repo = %task.repo,
1061 dispatch_key,
1062 age_mins = timing.age.num_minutes(),
1063 "skipping cross-repo stuck-task reclaim: runner still actively processing this task"
1064 );
1065 continue;
1066 }
1067 }
1068
1069 if timing.has_session {
1070 tracing::warn!(
1071 task_id = task.id,
1072 repo = %task.repo,
1073 age_mins = timing.age.num_minutes(),
1074 threshold_mins = timing.threshold / 60,
1075 "recovering cross-repo stuck task: timed out with active session → new"
1076 );
1077 } else {
1078 tracing::warn!(
1079 task_id = task.id,
1080 repo = %task.repo,
1081 dispatch_key = format!("{}/{external_id}", task.repo),
1082 age_mins = timing.age.num_minutes(),
1083 threshold_mins = timing.threshold / 60,
1084 "recovering cross-repo stuck task: no session found — reclaiming early → new"
1085 );
1086 }
1087
1088 if let Err(e) = store_set_by_id(
1089 &Some(store),
1090 task.id,
1091 &[
1092 ("agent", serde_json::Value::Null),
1093 ("model", serde_json::Value::Null),
1094 ("route_attempts", serde_json::json!(0)),
1095 ],
1096 )
1097 .await
1098 {
1099 tracing::warn!(
1100 task_id = task.id,
1101 repo = %task.repo,
1102 ?e,
1103 "failed to clear routing fields for cross-repo stuck task — will retry next tick"
1104 );
1105 continue;
1106 }
1107
1108 finalize_open_runs_for_recovery(
1109 store,
1110 task.id,
1111 if timing.has_session {
1112 "stuck-task recovery: cross-repo active session killed"
1113 } else {
1114 "stuck-task recovery: cross-repo no session found"
1115 },
1116 )
1117 .await;
1118
1119 if let Err(e) = store
1120 .update_status(task.id, crate::store::TaskStatus::New)
1121 .await
1122 {
1123 tracing::warn!(
1124 task_id = task.id,
1125 repo = %task.repo,
1126 ?e,
1127 "failed to reset cross-repo stuck task status"
1128 );
1129 continue;
1130 }
1131
1132 let details = serde_json::json!({
1133 "failure_reason": if timing.has_session { "stuck_with_session" } else { "stuck_no_session" },
1134 "age_minutes": timing.age.num_minutes(),
1135 "had_session": timing.has_session,
1136 "scope": "cross_repo_global_sweep",
1137 });
1138 crate::store::store_log_activity(
1139 &Some(Arc::clone(store)),
1140 &task.repo,
1141 &external_id,
1142 "rerouted",
1143 Some("in_progress"),
1144 Some("new"),
1145 None::<&str>,
1146 None::<&str>,
1147 Some(&details),
1148 )
1149 .await;
1150 }
1151
1152 use crate::store::TaskStatus as DbStatus;
1155 let internal_in_progress = match task_manager
1156 .list_internal_by_status(DbStatus::InProgress)
1157 .await
1158 {
1159 Ok(tasks) => tasks,
1160 Err(e) => {
1161 tracing::warn!(
1162 ?e,
1163 "failed to list internal in_progress tasks for stuck recovery"
1164 );
1165 vec![]
1166 }
1167 };
1168 for task in &internal_in_progress {
1169 let task_id = task.id.0.clone();
1170 let Some(timing) = stuck_task_timing_from_map(
1171 tmux,
1172 repo,
1173 &task_id,
1174 &task.updated_at,
1175 config,
1176 "cannot parse updated_at, skipping stuck internal-task check",
1177 session_map,
1178 ) else {
1179 continue;
1180 };
1181
1182 if !timing.has_session {
1188 let dispatch_key = format!("{repo}/{task_id}");
1189 if dispatching.contains_key(&dispatch_key) {
1190 tracing::info!(
1200 task_id,
1201 dispatch_key,
1202 age_mins = timing.age.num_minutes(),
1203 "skipping stuck-task reclaim: runner still actively processing this task"
1204 );
1205 continue;
1206 }
1207 }
1208
1209 if timing.has_session {
1210 tracing::warn!(
1211 task_id,
1212 age_mins = timing.age.num_minutes(),
1213 threshold_mins = timing.threshold / 60,
1214 "recovering stuck task: timed out with active session → new"
1215 );
1216 } else {
1217 tracing::warn!(
1218 task_id,
1219 dispatch_key = format!("{repo}/{task_id}"),
1220 age_mins = timing.age.num_minutes(),
1221 threshold_mins = timing.threshold / 60,
1222 "recovering stuck task: no session found — reclaiming early → new"
1223 );
1224 }
1225 let mut cached_store_id: Option<i64> = None;
1229 if timing.has_session {
1230 let store_task = match store.resolve_task_id(repo, &task_id).await {
1231 Ok(Some(store_id)) => {
1232 cached_store_id = Some(store_id);
1233 match store.get(store_id).await {
1234 Ok(t) => Some(t),
1235 Err(e) => {
1236 tracing::warn!(task_id, error = %e, "failed to fetch task from store for stuck internal-task cooldown");
1237 None
1238 }
1239 }
1240 }
1241 Ok(None) => None,
1242 Err(e) => {
1243 tracing::warn!(task_id, error = %e, "failed to resolve task id for stuck internal-task cooldown");
1244 None
1245 }
1246 };
1247 let agent_name = store_task
1248 .as_ref()
1249 .and_then(|t| t.agent.clone())
1250 .unwrap_or_default();
1251 let model_name = store_task
1252 .as_ref()
1253 .and_then(|t| t.model.clone())
1254 .unwrap_or_default();
1255
1256 if !agent_name.is_empty() && !model_name.is_empty() {
1257 set_model_cooldown(
1258 &agent_name,
1259 &model_name,
1260 config.silence_cooldown,
1261 "silence_detected",
1262 )
1263 .await;
1264 record_agent_failure_with_message(
1265 &agent_name,
1266 &format!(
1267 "internal task stuck with active session after {}m",
1268 timing.age.num_minutes()
1269 ),
1270 )
1271 .await;
1272 }
1273 if !agent_name.is_empty() {
1274 set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
1275 }
1276 tracing::warn!(
1277 task_id,
1278 agent = %agent_name,
1279 model = %model_name,
1280 cooldown_secs = config.silence_cooldown,
1281 "internal stuck-task cooldown applied — router will avoid this agent/model on next attempt"
1282 );
1283 }
1284 let resolved_store_id = match cached_store_id {
1287 Some(id) => Some(id),
1288 None => match store.resolve_task_id(repo, &task_id).await {
1289 Ok(Some(id)) => Some(id),
1290 Ok(None) => {
1291 tracing::warn!(
1292 task_id = %task_id,
1293 repo,
1294 "resolve_task_id returned None during stuck internal-task recovery"
1295 );
1296 None
1297 }
1298 Err(e) => {
1299 tracing::error!(
1300 task_id = %task_id,
1301 repo,
1302 error = %e,
1303 "resolve_task_id failed during stuck internal-task recovery"
1304 );
1305 None
1306 }
1307 },
1308 };
1309 if let Some(store_id) = resolved_store_id {
1310 if let Err(e) = store_set_by_id(
1311 &Some(store),
1312 store_id,
1313 &[
1314 ("agent", serde_json::Value::Null),
1315 ("model", serde_json::Value::Null),
1316 ("route_attempts", serde_json::json!(0)),
1317 ],
1318 )
1319 .await
1320 {
1321 tracing::warn!(
1322 task_id,
1323 ?e,
1324 "failed to clear routing fields for stuck internal task — will retry next tick"
1325 );
1326 continue;
1327 }
1328 finalize_open_runs_for_recovery(
1329 store,
1330 store_id,
1331 if timing.has_session {
1332 "stuck-task recovery: internal active session killed"
1333 } else {
1334 "stuck-task recovery: internal no session found"
1335 },
1336 )
1337 .await;
1338 }
1339 if let Err(e) = task_manager
1340 .update_task_status(&ExternalId(task_id.clone()), Status::New)
1341 .await
1342 {
1343 tracing::warn!(task_id, ?e, "failed to reset stuck internal task status");
1344 }
1345 }
1346
1347 let in_review = match task_manager.list_external_by_status(Status::InReview).await {
1351 Ok(tasks) => tasks,
1352 Err(e) => {
1353 tracing::warn!(?e, "failed to list in_review tasks for stuck recovery");
1354 vec![]
1355 }
1356 };
1357 let store_in_review = match store
1361 .list_by_status(repo, crate::store::TaskStatus::InReview)
1362 .await
1363 {
1364 Ok(v) => v,
1365 Err(e) => {
1366 tracing::warn!(
1367 ?e,
1368 "failed to list store in_review tasks for review_session_expected prefetch"
1369 );
1370 vec![]
1371 }
1372 };
1373 let review_expected_ids: std::collections::HashMap<String, i64> = store_in_review
1374 .into_iter()
1375 .filter(|t| t.review_session_expected)
1376 .filter_map(|t| t.external_id.map(|eid| (eid, t.id)))
1377 .collect();
1378 for task in &in_review {
1379 let Some(&store_id) = review_expected_ids.get(&task.id.0) else {
1380 tracing::debug!(
1381 task_id = task.id.0,
1382 "in_review task is waiting on PR review, skipping stuck-session recovery"
1383 );
1384 continue;
1385 };
1386
1387 let review_task_id = format!("{}-review", task.id.0);
1388 let in_review_config = crate::engine::EngineConfig {
1396 no_session_stuck_timeout: config.in_review_no_session_stuck_timeout,
1397 ..config.clone()
1398 };
1399 let age_at = in_review_stuck_timestamp(store, store_id, &task.updated_at).await;
1400 let Some(timing) = stuck_task_timing_from_map(
1401 tmux,
1402 repo,
1403 &review_task_id,
1404 &age_at,
1405 &in_review_config,
1406 "cannot parse in_review timestamp, skipping stuck in_review check",
1407 session_map,
1408 ) else {
1409 continue;
1410 };
1411
1412 tracing::warn!(
1413 task_id = task.id.0,
1414 age_mins = timing.age.num_minutes(),
1415 threshold_mins = timing.threshold / 60,
1416 "recovering stuck in_review task: no session found — resetting to needs_review"
1417 );
1418 if let Err(e) = task_manager
1419 .update_task_status(&task.id, Status::NeedsReview)
1420 .await
1421 {
1422 tracing::warn!(
1423 task_id = task.id.0,
1424 ?e,
1425 "failed to reset stuck in_review task status"
1426 );
1427 continue;
1428 } else {
1429 set_review_session_expected(store, repo, &task.id.0, false).await;
1430 }
1431
1432 if let Ok(Some(store_id)) = store.resolve_task_id(repo, &task.id.0).await {
1434 finalize_open_runs_for_recovery(
1435 store,
1436 store_id,
1437 "stuck-task recovery: in_review session killed",
1438 )
1439 .await;
1440 }
1441
1442 let details = serde_json::json!({
1444 "failure_reason": "stuck_in_review",
1445 "age_minutes": timing.age.num_minutes(),
1446 "had_session": timing.has_session,
1447 });
1448 crate::store::store_log_activity(
1449 &Some(Arc::clone(store)),
1450 repo,
1451 &task.id.0,
1452 "rerouted",
1453 Some("in_review"),
1454 Some("needs_review"),
1455 None::<&str>,
1456 None::<&str>,
1457 Some(&details),
1458 )
1459 .await;
1460 if let Err(e) = backend
1461 .post_comment(
1462 &task.id,
1463 &format!(
1464 "[{}] recovered: stuck in_review — no session found after {}m, resetting to needs_review{}",
1465 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
1466 timing.age.num_minutes(),
1467 crate::engine::orch_footer()
1468 ),
1469 )
1470 .await
1471 {
1472 tracing::warn!(
1473 task_id = task.id.0,
1474 ?e,
1475 "failed to post stuck in_review recovery comment"
1476 );
1477 }
1478 }
1479
1480 let internal_in_review = match task_manager
1482 .list_internal_by_status(DbStatus::InReview)
1483 .await
1484 {
1485 Ok(tasks) => tasks,
1486 Err(e) => {
1487 tracing::warn!(
1488 ?e,
1489 "failed to list internal in_review tasks for stuck recovery"
1490 );
1491 vec![]
1492 }
1493 };
1494 for task in &internal_in_review {
1495 let task_id = task.id.0.clone();
1496 let Some(&store_id) = review_expected_ids.get(&task_id) else {
1497 tracing::debug!(
1498 task_id,
1499 "internal in_review task is waiting on PR review, skipping stuck-session recovery"
1500 );
1501 continue;
1502 };
1503
1504 let review_task_id = format!("{}-review", task_id);
1505 let in_review_config = crate::engine::EngineConfig {
1506 no_session_stuck_timeout: config.in_review_no_session_stuck_timeout,
1507 ..config.clone()
1508 };
1509 let age_at = in_review_stuck_timestamp(store, store_id, &task.updated_at).await;
1510 let Some(timing) = stuck_task_timing_from_map(
1511 tmux,
1512 repo,
1513 &review_task_id,
1514 &age_at,
1515 &in_review_config,
1516 "cannot parse in_review timestamp, skipping stuck internal in_review check",
1517 session_map,
1518 ) else {
1519 continue;
1520 };
1521
1522 tracing::warn!(
1523 task_id,
1524 age_mins = timing.age.num_minutes(),
1525 threshold_mins = timing.threshold / 60,
1526 "recovering stuck internal in_review task: no session found — resetting to needs_review"
1527 );
1528 if let Err(e) = task_manager
1529 .update_task_status(&ExternalId(task_id.clone()), Status::NeedsReview)
1530 .await
1531 {
1532 tracing::warn!(
1533 task_id,
1534 ?e,
1535 "failed to reset stuck internal in_review task status"
1536 );
1537 } else {
1538 set_review_session_expected(store, repo, &task_id, false).await;
1539 }
1540
1541 if let Ok(Some(store_id)) = store.resolve_task_id(repo, &task_id).await {
1543 finalize_open_runs_for_recovery(
1544 store,
1545 store_id,
1546 "stuck-task recovery: internal in_review session killed",
1547 )
1548 .await;
1549 }
1550 }
1551
1552 if let Err(e) = crate::engine::sync::auto_unblock_ci_failure_blocked_tasks_global(
1557 task_manager,
1558 store,
1559 auto_merge_in_flight,
1560 )
1561 .await
1562 {
1563 tracing::warn!(err = %e, "global CI-failure blocked sweep failed");
1564 }
1565
1566 if let Err(e) = crate::engine::sync::refire_and_escalate_stale_needs_review_global(
1569 task_manager,
1570 store,
1571 router,
1572 dispatching,
1573 )
1574 .await
1575 {
1576 tracing::warn!(err = %e, "global NeedsReview refire/escalation sweep failed");
1577 }
1578
1579 if let Err(e) = crate::engine::sync::auto_recover_rebroadcast_blocked_tasks_global(
1582 router,
1583 task_manager,
1584 store,
1585 )
1586 .await
1587 {
1588 tracing::warn!(err = %e, "global rebroadcast-blocked recovery sweep failed");
1589 }
1590
1591 Ok(())
1592}
1593
1594pub(crate) async fn tick_route_tasks(
1596 backend: &Arc<dyn ExternalBackend>,
1597 task_manager: &Arc<TaskManager>,
1598 router: &mut Router,
1599 store: &Arc<TaskStore>,
1600 repo: &str,
1601 routing_quota: &mut RoutingTickQuota,
1602) -> anyhow::Result<()> {
1603 let _span = tracing::info_span!("engine.tick.phase3a.route").entered();
1604 if let Err(err) = crate::engine::dependencies::apply_claim_gates(store).await {
1605 tracing::warn!(err = %err, "dependency claim gate failed");
1606 }
1607 if routing_quota.is_exhausted() {
1608 tracing::debug!("routing quota exhausted for this tick — skipping repo routing phase");
1609 return Ok(());
1610 }
1611
1612 if is_github_circuit_open() {
1616 let remaining = github_circuit_remaining_secs();
1617 tracing::info!(
1618 remaining_secs = remaining,
1619 "GitHub 5xx circuit breaker open — skipping routing phase"
1620 );
1621 return Ok(());
1622 }
1623 let new_tasks = task_manager.list_routable().await?;
1624 let routable: Vec<&ExternalTask> = new_tasks
1625 .iter()
1626 .filter(|t| !skips_agent(&t.labels))
1627 .collect();
1628
1629 if !routable.is_empty() {
1633 let start = std::time::Instant::now();
1634 router.refresh_health(store).await;
1635 tracing::debug!(
1636 duration_ms = start.elapsed().as_millis(),
1637 "router.refresh_health completed"
1638 );
1639 }
1640
1641 for task in routable.into_iter().take(routing_quota.remaining()) {
1643 if !routing_quota.consume_one() {
1644 break;
1645 }
1646 let _task_span = tracing::info_span!("engine.route", task_id = %task.id.0).entered();
1647
1648 let task_start = Instant::now();
1649 match router.route(task, store, repo).await {
1650 Ok(result) => {
1651 tracing::debug!(task_id = %task.id.0, duration_ms = task_start.elapsed().as_millis(), "route completed");
1652 if let Err(e) = router
1654 .store_route_result(&task.id.0, &result, store, repo)
1655 .await
1656 {
1657 tracing::warn!(
1658 task_id = task.id.0,
1659 ?e,
1660 "failed to store route result; skipping Routed transition"
1661 );
1662 continue;
1663 }
1664
1665 if is_internal_id(&task.id.0) {
1666 if let Err(e) = task_manager
1668 .update_task_status(&task.id, Status::Routed)
1669 .await
1670 {
1671 tracing::warn!(
1672 task_id = task.id.0,
1673 ?e,
1674 "failed to set internal status:routed"
1675 );
1676 continue;
1677 }
1678 } else {
1679 let backend_clone = Arc::clone(backend);
1683 let task_id_clone = task.id.clone();
1684 let task_labels: Vec<String> = task.labels.clone();
1685 let new_labels = {
1686 let mut labels = vec![
1687 format!("agent:{}", result.agent),
1688 format!("complexity:{}", result.complexity),
1689 ];
1690 if let Some(ref model) = result.model {
1691 labels.push(format!("model:{model}"));
1692 }
1693 labels
1694 };
1695 tokio::spawn(async move {
1696 for label in &task_labels {
1698 if label.starts_with("agent:")
1699 || label.starts_with("complexity:")
1700 || label.starts_with("model:")
1701 {
1702 if let Err(e) =
1703 backend_clone.remove_label(&task_id_clone, label).await
1704 {
1705 tracing::warn!(task_id = task_id_clone.0, label, error = %e, "failed to remove stale routing label during re-route");
1706 }
1707 }
1708 }
1709 if let Err(e) = backend_clone.set_labels(&task_id_clone, &new_labels).await
1711 {
1712 tracing::warn!(
1713 task_id = task_id_clone.0,
1714 ?e,
1715 "failed to set routing labels"
1716 );
1717 }
1718 });
1719
1720 if let Err(e) = task_manager
1722 .update_task_status(&task.id, Status::Routed)
1723 .await
1724 {
1725 tracing::warn!(task_id = task.id.0, ?e, "failed to set status:routed");
1726 continue;
1727 }
1728 }
1729
1730 match store.ensure_external_task(repo, task).await {
1732 Ok(store_id) => {
1733 let profile_json =
1734 serde_json::to_string(&result.profile).unwrap_or_default();
1735 let skills_json =
1736 serde_json::to_string(&result.selected_skills).unwrap_or_default();
1737 if let Err(e) = store
1738 .store_route(&crate::store::StoreRoute {
1739 id: store_id,
1740 agent: &result.agent,
1741 model: result.model.as_deref(),
1742 complexity: &result.complexity,
1743 estimate: result.estimate,
1744 reason: &result.reason,
1745 profile: &profile_json,
1746 skills: &skills_json,
1747 })
1748 .await
1749 {
1750 tracing::debug!(
1751 task_id = task.id.0,
1752 ?e,
1753 "dual-write: store_route failed"
1754 );
1755 }
1756 if let Err(e) = store
1757 .update_status(store_id, crate::store::TaskStatus::Routed)
1758 .await
1759 {
1760 tracing::debug!(
1761 task_id = task.id.0,
1762 ?e,
1763 "dual-write: update_status failed"
1764 );
1765 }
1766 if result.estimate > 0 {
1770 let backend_clone = Arc::clone(backend);
1771 let task_id = task.id.clone();
1772 let estimate = result.estimate;
1773 tokio::spawn(async move {
1774 if let Err(e) = backend_clone
1775 .sync_estimate_to_project(&task_id, estimate)
1776 .await
1777 {
1778 tracing::debug!(
1779 task_id = task_id.0,
1780 estimate,
1781 err = %e,
1782 "dual-write: estimate project sync failed"
1783 );
1784 }
1785 });
1786 }
1787 }
1788 Err(e) => {
1789 tracing::debug!(
1790 task_id = task.id.0,
1791 ?e,
1792 "dual-write: ensure_external_task failed"
1793 );
1794 }
1795 }
1796
1797 if let Some(ref warning) = result.warning {
1798 tracing::warn!(task_id = task.id.0, warning, "routing sanity warning");
1799 }
1800
1801 tracing::info!(
1802 task_id = task.id.0,
1803 agent = %result.agent,
1804 complexity = %result.complexity,
1805 reason = %result.reason,
1806 "task routed"
1807 );
1808 }
1809 Err(e) => {
1810 if let Some(cooled) = e.downcast_ref::<AllAgentsCooledError>() {
1811 tracing::error!(
1812 task_id = %task.id.0,
1813 scope = %cooled.scope(),
1814 remaining_secs = ?cooled.remaining_secs(),
1815 "ALL AGENTS COOLED — no agent available to route this task. \
1816 Task stays in 'new' and will be retried on every tick. \
1817 Check `orch cooldown list` to see what is blocking dispatch."
1818 );
1819 continue;
1820 }
1821 if let Some(waiting) = e.downcast_ref::<crate::store::WaitingOnDependency>() {
1822 tracing::info!(
1823 task_id = %task.id.0,
1824 blockers = %waiting.blockers.join(", "),
1825 "task is waiting on a dependency"
1826 );
1827 continue;
1828 }
1829 tracing::error!(task_id = task.id.0, ?e, "routing failed, skipping task");
1830 }
1831 }
1832 }
1833 Ok(())
1834}
1835
1836pub(crate) async fn route_new_tasks_global(
1852 router: &mut Router,
1853 store: &Arc<TaskStore>,
1854 active_repos: &std::collections::HashSet<String>,
1855 routing_quota: &mut RoutingTickQuota,
1856) -> anyhow::Result<()> {
1857 use crate::store::{StoreRoute, TaskStatus};
1858
1859 if routing_quota.is_exhausted() {
1860 tracing::debug!("routing quota exhausted for this tick — skipping global routing sweep");
1861 return Ok(());
1862 }
1863
1864 let all_new = store.list_all_by_status_global(TaskStatus::New).await?;
1865 let orphaned: Vec<&crate::store::Task> = all_new
1866 .iter()
1867 .filter(|t| !active_repos.contains(&t.repo))
1868 .filter(|t| !skips_agent(&t.labels))
1869 .collect();
1870
1871 if orphaned.is_empty() {
1872 return Ok(());
1873 }
1874
1875 tracing::info!(
1876 count = orphaned.len(),
1877 "global routing sweep: found new tasks from inactive repos"
1878 );
1879
1880 router.refresh_health(store).await;
1881
1882 for task in orphaned.into_iter().take(routing_quota.remaining()) {
1883 if !routing_quota.consume_one() {
1884 break;
1885 }
1886 let ext_task = crate::engine::tasks::store_task_to_external(task);
1887 let task_start = Instant::now();
1888 match router.route(&ext_task, store, &task.repo).await {
1889 Ok(result) => {
1890 tracing::debug!(
1891 task_id = %ext_task.id.0,
1892 repo = %task.repo,
1893 duration_ms = task_start.elapsed().as_millis(),
1894 "global sweep: route completed"
1895 );
1896 let profile_json = serde_json::to_string(&result.profile).unwrap_or_default();
1897 let skills_json =
1898 serde_json::to_string(&result.selected_skills).unwrap_or_default();
1899 if let Err(e) = store
1900 .store_route(&StoreRoute {
1901 id: task.id,
1902 agent: &result.agent,
1903 model: result.model.as_deref(),
1904 complexity: &result.complexity,
1905 estimate: result.estimate,
1906 reason: &result.reason,
1907 profile: &profile_json,
1908 skills: &skills_json,
1909 })
1910 .await
1911 {
1912 tracing::warn!(
1913 task_id = %ext_task.id.0,
1914 repo = %task.repo,
1915 err = %e,
1916 "global sweep: failed to store route result; skipping status update"
1917 );
1918 continue;
1919 }
1920 if let Err(e) = store.update_status(task.id, TaskStatus::Routed).await {
1921 tracing::warn!(
1922 task_id = %ext_task.id.0,
1923 repo = %task.repo,
1924 err = %e,
1925 "global sweep: failed to set status:routed"
1926 );
1927 } else {
1928 tracing::info!(
1929 task_id = %ext_task.id.0,
1930 repo = %task.repo,
1931 agent = %result.agent,
1932 complexity = %result.complexity,
1933 "global sweep: task routed"
1934 );
1935 }
1936 if let Some(ref warning) = result.warning {
1937 tracing::warn!(task_id = %ext_task.id.0, warning, "global sweep: routing sanity warning");
1938 }
1939 }
1940 Err(e) => {
1941 if let Some(cooled) = e.downcast_ref::<AllAgentsCooledError>() {
1942 tracing::error!(
1943 task_id = %ext_task.id.0,
1944 repo = %task.repo,
1945 scope = %cooled.scope(),
1946 remaining_secs = ?cooled.remaining_secs(),
1947 "global sweep: ALL AGENTS COOLED — task stays in 'new'"
1948 );
1949 continue;
1950 }
1951 if let Some(waiting) = e.downcast_ref::<crate::store::WaitingOnDependency>() {
1952 tracing::info!(
1953 task_id = %ext_task.id.0,
1954 repo = %task.repo,
1955 blockers = %waiting.blockers.join(", "),
1956 "global sweep: task is waiting on a dependency"
1957 );
1958 continue;
1959 }
1960 tracing::error!(
1961 task_id = %ext_task.id.0,
1962 repo = %task.repo,
1963 err = ?e,
1964 "global sweep: routing failed, skipping task"
1965 );
1966 }
1967 }
1968 }
1969
1970 Ok(())
1971}
1972
1973#[derive(Debug, Clone, Copy)]
1975pub(crate) struct DispatchMode {
1976 pub(crate) is_degraded: bool,
1977 pub(crate) healthy_agents: usize,
1978 pub(crate) threshold: usize,
1979}
1980
1981pub(crate) fn dispatch_mode_from_router(router: &Router) -> DispatchMode {
1982 let threshold = crate::engine::router::config::min_healthy_agents_threshold();
1983 let healthy_agents = router.healthy_agent_count("simple");
1984 let is_degraded = router.is_degraded(threshold);
1985 DispatchMode {
1986 is_degraded,
1987 healthy_agents,
1988 threshold,
1989 }
1990}
1991
1992#[allow(clippy::too_many_arguments)]
1994#[tracing::instrument(skip_all, name = "engine.tick.phase3b.dispatch")]
1995pub(crate) async fn tick_dispatch_tasks(
1996 backend: &Arc<dyn ExternalBackend>,
1997 tmux: &Arc<TmuxManager>,
1998 repo: &str,
1999 runner: &Arc<TaskRunner>,
2000 capture: &Arc<CaptureService>,
2001 semaphore: &Arc<Semaphore>,
2002 task_manager: &Arc<TaskManager>,
2003 weight_tx: &mpsc::Sender<WeightSignal>,
2004 dispatch_mode: DispatchMode,
2005 dispatching: &Arc<DashMap<String, String>>,
2006 store: &Arc<TaskStore>,
2007 session_map: &std::collections::HashMap<String, bool>,
2008) -> anyhow::Result<()> {
2009 let mut routed_tasks = task_manager.list_external_by_status(Status::Routed).await?;
2011
2012 use crate::store::TaskStatus as DbStatus;
2014 let internal_routed = task_manager
2015 .list_internal_by_status(DbStatus::Routed)
2016 .await?;
2017 routed_tasks.extend(internal_routed);
2018
2019 let dispatchable: Vec<ExternalTask> = routed_tasks
2021 .into_iter()
2022 .filter(|t| !skips_agent(&t.labels))
2023 .collect();
2024
2025 dispatch_tasks_for_repo(
2026 backend,
2027 tmux,
2028 repo,
2029 runner,
2030 capture,
2031 semaphore,
2032 task_manager,
2033 weight_tx,
2034 dispatch_mode,
2035 dispatching,
2036 store,
2037 session_map,
2038 dispatchable,
2039 )
2040 .await
2041}
2042
2043#[allow(clippy::too_many_arguments)]
2049async fn dispatch_tasks_for_repo(
2050 backend: &Arc<dyn ExternalBackend>,
2051 tmux: &Arc<TmuxManager>,
2052 repo: &str,
2053 runner: &Arc<TaskRunner>,
2054 capture: &Arc<CaptureService>,
2055 semaphore: &Arc<Semaphore>,
2056 task_manager: &Arc<TaskManager>,
2057 weight_tx: &mpsc::Sender<WeightSignal>,
2058 dispatch_mode: DispatchMode,
2059 dispatching: &Arc<DashMap<String, String>>,
2060 store: &Arc<TaskStore>,
2061 session_map: &std::collections::HashMap<String, bool>,
2062 dispatchable: Vec<ExternalTask>,
2063) -> anyhow::Result<()> {
2064 if dispatchable.is_empty() {
2065 tracing::debug!(count = 0, "dispatchable tasks found");
2066 return Ok(());
2067 }
2068
2069 tracing::info!(count = dispatchable.len(), "dispatchable tasks found");
2070 if dispatch_mode.is_degraded {
2071 tracing::warn!(
2072 healthy_agents = dispatch_mode.healthy_agents,
2073 threshold = dispatch_mode.threshold,
2074 "degraded mode: using sequential dispatch"
2075 );
2076 }
2077
2078 let sequential_delay = if dispatch_mode.is_degraded {
2079 crate::engine::router::config::sequential_dispatch_delay_ms()
2080 } else {
2081 0
2082 };
2083
2084 for (idx, task) in dispatchable.into_iter().enumerate() {
2085 if crate::engine::dependencies::dispatch_is_waiting(store, repo, &task.id.0).await {
2086 continue;
2087 }
2088 if idx > 0 && dispatch_mode.is_degraded {
2090 let delay_ms = sequential_delay;
2091 tracing::debug!(
2092 task_id = task.id.0,
2093 delay_ms = delay_ms,
2094 "sequential dispatch: waiting before dispatch"
2095 );
2096 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
2097 }
2098 let dispatch_key = format!("{}/{}", repo, task.id.0);
2105 {
2110 use dashmap::mapref::entry::Entry;
2111 match dispatching.entry(dispatch_key.clone()) {
2112 Entry::Occupied(existing) => {
2113 let existing_id = existing.get().clone();
2114 drop(existing); if existing_id == task.id.0 {
2116 tracing::debug!(
2117 task_id = task.id.0,
2118 "task already dispatching, skipping duplicate"
2119 );
2120 } else {
2121 tracing::warn!(
2122 task_id = task.id.0,
2123 existing_task_id = existing_id,
2124 dispatch_key,
2125 "dispatch key collision: unexpected task already holds this key"
2126 );
2127 }
2128 continue;
2129 }
2130 Entry::Vacant(slot) => {
2131 slot.insert(task.id.0.clone());
2132 tracing::info!(task_id = task.id.0, dispatch_key, "dispatch guard claimed");
2139 }
2140 }
2141 }
2142 let dispatch_guard = DispatchGuard::new(dispatching.clone(), dispatch_key.clone());
2144 let task_id = task.id.0.clone();
2145
2146 let route_result = match get_route_result(store, repo, &task_id).await {
2149 Ok(r) => Some(r),
2150 Err(RouteResultError::NoAgent { .. }) => {
2151 tracing::warn!(
2152 task_id,
2153 "routed task missing agent — resetting to new for re-routing (#1604)"
2154 );
2155 if let Err(e2) = task_manager.update_task_status(&task.id, Status::New).await {
2156 tracing::error!(task_id, error = %e2, "failed to reset task to new");
2157 }
2158 continue; }
2160 Err(e) => {
2161 tracing::warn!(task_id, error = %e, "get_route_result failed — dispatching without route info");
2162 None
2163 }
2164 };
2165
2166 if let Some(route) = route_result.as_ref() {
2172 let agent_cooled = is_agent_in_cooldown(&route.agent);
2173 let model_cooled = route
2174 .model
2175 .as_deref()
2176 .is_some_and(|model| is_model_in_cooldown(&route.agent, model));
2177 if agent_cooled || model_cooled {
2178 tracing::warn!(
2179 task_id,
2180 agent = route.agent,
2181 model = route.model.as_deref().unwrap_or(""),
2182 "routed agent/model entered cooldown after routing — deferring dispatch to next tick"
2183 );
2184 continue; }
2186 }
2187
2188 let session_name = tmux.session_name(repo, &task_id);
2190 if tmux
2191 .session_blocks_dispatch_from_map(&session_name, session_map)
2192 .await
2193 {
2194 tracing::warn!(
2195 task_id,
2196 session_name,
2197 "task has existing tmux session, skipping dispatch"
2198 );
2199 continue; }
2201
2202 let permit = match semaphore.clone().try_acquire_owned() {
2204 Ok(p) => p,
2205 Err(_) => {
2206 tracing::debug!("all parallel slots busy, skipping remaining tasks");
2207 break; }
2209 };
2210
2211 let set_in_progress_result = task_manager
2213 .update_task_status(&task.id, Status::InProgress)
2214 .await;
2215 if let Err(e) = set_in_progress_result {
2216 tracing::error!(task_id, ?e, "failed to set in_progress, skipping dispatch");
2217 drop(permit);
2218 continue; }
2220 tracing::info!(task_id, "dispatching task");
2221
2222 capture
2224 .register_session(repo, &task_id, &session_name)
2225 .await;
2226
2227 let runner = runner.clone();
2229 let backend = backend.clone();
2230 let tmux = tmux.clone();
2231 let capture = capture.clone();
2232 let task_id_for_cleanup = task_id.clone();
2233 let task_owned = task;
2234 let weight_tx = weight_tx.clone();
2235 let repo_owned = repo.to_string();
2236 let task_manager_for_spawn = task_manager.clone();
2237 let store_for_spawn = store.clone();
2238
2239 let repo_ctx = repo_owned.clone();
2240 tokio::spawn(REPO_CONTEXT.scope(repo_ctx, async move {
2241 let _dispatch_guard = dispatch_guard; let dispatch_start = std::time::Instant::now();
2243 match runner
2244 .run_with_context(&task_owned, &backend, &tmux, route_result.as_ref())
2245 .await
2246 {
2247 Ok(signal) => {
2248 tracing::info!(task_id, "task runner completed");
2249
2250 let duration = dispatch_start.elapsed().as_secs_f64();
2251
2252 let enable_review = config::get("workflow.enable_review_agent")
2258 .map(|v| v != "false")
2259 .unwrap_or(true);
2260 let display_status = match &signal {
2261 WeightSignal::Success { .. } if enable_review => {
2262 let has_pr = match store_for_spawn
2266 .resolve_task_id(&repo_owned, &task_id)
2267 .await
2268 {
2269 Ok(Some(store_id)) => match store_for_spawn
2270 .get(store_id)
2271 .await
2272 {
2273 Ok(t) => t.pr_number.is_some(),
2274 Err(e) => {
2275 tracing::warn!(task_id, err = %e, "store.get() failed — defaulting to needs_review");
2276 true
2277 }
2278 },
2279 Ok(None) => false,
2280 Err(e) => {
2281 tracing::warn!(task_id, err = %e, "failed to check PR status — defaulting to needs_review");
2282 true
2283 }
2284 };
2285 if has_pr {
2286 "needs_review"
2287 } else {
2288 "done"
2289 }
2290 }
2291 WeightSignal::Success { .. } => "done",
2292 WeightSignal::RateLimited { .. } => "new",
2293 WeightSignal::Blocked => "blocked",
2294 WeightSignal::Rerouted => "new",
2299 WeightSignal::None if enable_review => "needs_review",
2304 WeightSignal::None => "done",
2305 };
2306
2307 let _ = weight_tx.send(signal).await;
2309
2310 if display_status == "needs_review" {
2314 if let Err(e) = task_manager_for_spawn
2317 .update_task_status_with_duration(
2318 &ExternalId(task_id.clone()),
2319 Status::NeedsReview,
2320 Some(duration),
2321 )
2322 .await
2323 {
2324 tracing::error!(task_id, err = %e, "update_task_status(NeedsReview) failed — task may be stuck");
2325 }
2326 } else {
2328 let final_status = match display_status {
2330 "done" => Status::Done,
2331 "blocked" => Status::Blocked,
2332 _ => Status::New,
2333 };
2334 if let Err(e) = task_manager_for_spawn
2335 .update_task_status_with_duration(
2336 &ExternalId(task_id.clone()),
2337 final_status,
2338 Some(duration),
2339 )
2340 .await
2341 {
2342 tracing::warn!(task_id, ?e, "failed to update task status after completion");
2343 }
2344 }
2345 }
2346 Err(e) => {
2347 tracing::error!(task_id, ?e, "task runner failed");
2348 let duration = dispatch_start.elapsed().as_secs_f64();
2349 if is_internal_id(&task_id) {
2350 if let Err(ue) = task_manager_for_spawn
2352 .update_task_status_with_duration(
2353 &ExternalId(task_id.clone()),
2354 Status::NeedsReview,
2355 Some(duration),
2356 )
2357 .await
2358 {
2359 tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
2360 }
2361 } else {
2362 if let Err(comment_err) = backend
2364 .post_comment(
2365 &ExternalId(task_id.clone()),
2366 &format!(
2367 "[{}] error: task runner failed: {e}{}",
2368 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
2369 crate::engine::orch_footer()
2370 ),
2371 )
2372 .await
2373 {
2374 tracing::warn!(
2375 task_id,
2376 ?comment_err,
2377 "failed to post error comment to GitHub"
2378 );
2379 }
2380 if let Err(ue) = task_manager_for_spawn
2384 .update_task_status_with_duration(
2385 &ExternalId(task_id.clone()),
2386 Status::NeedsReview,
2387 Some(duration),
2388 )
2389 .await
2390 {
2391 tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
2392 }
2393 }
2394 }
2395 }
2396
2397 capture.unregister_session(&repo_owned, &task_id_for_cleanup).await;
2399
2400 drop(permit);
2402 }));
2404 }
2405 Ok(())
2406}
2407
2408fn skips_agent(labels: &[String]) -> bool {
2409 labels
2410 .iter()
2411 .any(|label| label == "no-agent" || label == "owner:human")
2412}
2413
2414fn group_orphaned_routed_tasks(
2421 all_routed: Vec<crate::store::Task>,
2422 active_repos: &std::collections::HashSet<String>,
2423) -> std::collections::HashMap<String, Vec<crate::store::Task>> {
2424 let mut by_repo: std::collections::HashMap<String, Vec<crate::store::Task>> =
2425 std::collections::HashMap::new();
2426 for task in all_routed
2427 .into_iter()
2428 .filter(|t| !active_repos.contains(&t.repo))
2429 .filter(|t| !skips_agent(&t.labels))
2430 {
2431 by_repo.entry(task.repo.clone()).or_default().push(task);
2432 }
2433 by_repo
2434}
2435
2436#[allow(clippy::too_many_arguments)]
2456pub(crate) async fn dispatch_routed_tasks_global(
2457 tmux: &Arc<TmuxManager>,
2458 capture: &Arc<CaptureService>,
2459 semaphore: &Arc<Semaphore>,
2460 weight_tx: &mpsc::Sender<WeightSignal>,
2461 dispatch_mode: DispatchMode,
2462 dispatching: &Arc<DashMap<String, String>>,
2463 store: &Arc<TaskStore>,
2464 session_map: &std::collections::HashMap<String, bool>,
2465 active_repos: &std::collections::HashSet<String>,
2466) -> anyhow::Result<()> {
2467 use crate::store::TaskStatus;
2468
2469 let all_routed = store.list_all_by_status_global(TaskStatus::Routed).await?;
2470 let by_repo = group_orphaned_routed_tasks(all_routed, active_repos);
2471
2472 if by_repo.is_empty() {
2473 return Ok(());
2474 }
2475
2476 tracing::info!(
2477 repos = by_repo.len(),
2478 "global dispatch sweep: found routed tasks from inactive repos"
2479 );
2480
2481 for (repo, tasks) in by_repo {
2482 let backend: Arc<dyn ExternalBackend> = match crate::backends::github::GitHubBackend::new(
2483 repo.clone(),
2484 ) {
2485 Ok(b) => Arc::new(b),
2486 Err(e) => {
2487 tracing::warn!(repo = %repo, err = %e, "global sweep: failed to construct backend, skipping repo");
2488 continue;
2489 }
2490 };
2491 let task_manager = Arc::new(TaskManager::with_store(
2492 backend.clone(),
2493 store.clone(),
2494 repo.clone(),
2495 ));
2496 let runner = Arc::new(TaskRunner::new(repo.clone()).with_store(store.clone()));
2497 let dispatchable: Vec<ExternalTask> = tasks
2498 .iter()
2499 .map(crate::engine::tasks::store_task_to_external)
2500 .collect();
2501
2502 if let Err(e) = dispatch_tasks_for_repo(
2503 &backend,
2504 tmux,
2505 &repo,
2506 &runner,
2507 capture,
2508 semaphore,
2509 &task_manager,
2510 weight_tx,
2511 dispatch_mode,
2512 dispatching,
2513 store,
2514 session_map,
2515 dispatchable,
2516 )
2517 .await
2518 {
2519 tracing::warn!(repo = %repo, err = %e, "global sweep: dispatch failed for repo");
2520 }
2521 }
2522
2523 Ok(())
2524}
2525
2526pub(crate) async fn tick_unblock_parents(
2528 backend: &Arc<dyn ExternalBackend>,
2529 task_manager: &Arc<TaskManager>,
2530 store: &Arc<TaskStore>,
2531 repo: &str,
2532) -> anyhow::Result<()> {
2533 let blocked = task_manager.list_all_by_status(Status::Blocked).await?;
2534
2535 let store_blocked = store
2539 .list_by_status(repo, crate::store::TaskStatus::Blocked)
2540 .await?;
2541 let has_block_reason: std::collections::HashSet<String> = store_blocked
2542 .iter()
2543 .filter(|t| t.block_reason.is_some())
2544 .filter_map(|t| t.external_id.clone())
2545 .collect();
2546
2547 let handles: Vec<_> = blocked
2551 .into_iter()
2552 .filter(|task| {
2553 if has_block_reason.contains(&task.id.0) {
2554 tracing::debug!(
2555 task_id = task.id.0,
2556 "skipping blocked task with known block_reason"
2557 );
2558 false
2559 } else {
2560 true
2561 }
2562 })
2563 .map(|task| {
2564 let backend_clone = Arc::clone(backend);
2565 let task_manager_clone = Arc::clone(task_manager);
2566 let store_clone = Arc::clone(store);
2567 let repo_owned = repo.to_string();
2568 tokio::spawn(async move {
2569 let task_id = &task.id;
2570
2571 let mut children: Vec<ExternalId> = Vec::new();
2572 let mut child_statuses: std::collections::HashMap<
2573 String,
2574 crate::store::TaskStatus,
2575 > = std::collections::HashMap::new();
2576
2577 match store_clone.resolve_task_id(&repo_owned, &task_id.0).await {
2578 Ok(Some(parent_store_id)) => {
2579 match store_clone.list_children(&repo_owned, parent_store_id).await {
2580 Ok(store_children) => {
2581 for child in store_children {
2582 let Some(child_external_id) = child.external_id else {
2583 continue;
2584 };
2585 child_statuses.insert(child_external_id.clone(), child.status);
2586 children.push(ExternalId(child_external_id));
2587 }
2588 }
2589 Err(e) => {
2590 tracing::debug!(
2591 task_id = task_id.0,
2592 ?e,
2593 "failed to list store-linked children"
2594 );
2595 }
2596 }
2597 }
2598 Ok(None) => {}
2599 Err(e) => {
2600 tracing::debug!(
2601 task_id = task_id.0,
2602 ?e,
2603 "failed to resolve blocked task in store for child lookup"
2604 );
2605 }
2606 }
2607
2608 if !is_internal_id(&task_id.0) {
2609 match backend_clone.get_sub_issues(task_id).await {
2610 Ok(ids) => {
2611 let mut seen: std::collections::HashSet<String> =
2612 children.iter().map(|child| child.0.clone()).collect();
2613 for id in ids {
2614 if seen.insert(id.0.clone()) {
2615 children.push(id);
2616 }
2617 }
2618 }
2619 Err(e) => {
2620 tracing::debug!(task_id = task_id.0, ?e, "failed to get sub-issues");
2621 }
2622 }
2623 }
2624
2625 if children.is_empty() {
2627 return;
2628 }
2629
2630 let mut all_done = true;
2633 let child_ext_strs: Vec<String> =
2635 children.iter().map(|c| c.0.clone()).collect();
2636 let child_exts: Vec<&str> =
2637 child_ext_strs.iter().map(|s| s.as_str()).collect();
2638 if let Ok(map) = store_clone
2640 .get_statuses_by_external_ids(&repo_owned, &child_exts)
2641 .await
2642 {
2643 for (k, v) in map.into_iter() {
2644 child_statuses.insert(k, v);
2645 }
2646 } else {
2647 tracing::debug!(
2648 task_id = task_id.0,
2649 "failed to batch-query store for child statuses; falling back to per-child lookups"
2650 );
2651 }
2652
2653 for child_id in &children {
2654 if let Some(status) = child_statuses.get(&child_id.0) {
2656 if *status != crate::store::TaskStatus::Done {
2657 all_done = false;
2658 break;
2659 }
2660 continue;
2661 }
2662
2663 match backend_clone.get_task(child_id).await {
2665 Ok(child) => {
2666 if !child.labels.iter().any(|l| l == Status::Done.as_label()) {
2667 all_done = false;
2668 break;
2669 }
2670 }
2671 Err(e) => {
2672 tracing::debug!(task_id = task_id.0, child = %child_id.0, ?e, "failed to fetch child task from backend — treating as not done");
2673 all_done = false;
2674 break;
2675 }
2676 }
2677 }
2678
2679 if all_done {
2680 tracing::info!(
2681 task_id = task_id.0,
2682 children = children.len(),
2683 "all children done, unblocking parent"
2684 );
2685 if let Err(e) =
2686 task_manager_clone.update_task_status(task_id, Status::New).await
2687 {
2688 tracing::warn!(task_id = task_id.0, ?e, "failed to unblock parent");
2689 }
2690 }
2691 })
2692 })
2693 .collect();
2694
2695 for handle in handles {
2696 if let Err(e) = handle.await {
2697 tracing::warn!(?e, "phase 4 sub-issue check panicked");
2698 }
2699 }
2700 Ok(())
2701}
2702
2703pub(crate) async fn tick_job_scheduler(
2705 jobs_path: &std::path::Path,
2706 backend: &Arc<dyn ExternalBackend>,
2707 store: Option<&Arc<TaskStore>>,
2708 repo: &str,
2709 transport: Option<&Arc<crate::channels::transport::Transport>>,
2710) -> anyhow::Result<()> {
2711 jobs::tick(jobs_path, backend, store, repo, transport).await
2712}
2713
2714#[allow(clippy::too_many_arguments)]
2724pub(crate) async fn tick(
2725 backend: &Arc<dyn ExternalBackend>,
2726 tmux: &Arc<TmuxManager>,
2727 repo: &str,
2728 runner: &Arc<TaskRunner>,
2729 capture: &Arc<CaptureService>,
2730 semaphore: &Arc<Semaphore>,
2731 config: &EngineConfig,
2732 jobs_path: &std::path::Path,
2733 router: &mut Router,
2734 task_manager: &Arc<TaskManager>,
2735 weight_tx: &mpsc::Sender<WeightSignal>,
2736 dispatching: &Arc<DashMap<String, String>>,
2737 store: &Arc<TaskStore>,
2738 transport: Option<&Arc<crate::channels::transport::Transport>>,
2739 active_repos: &std::collections::HashSet<String>,
2740 auto_merge_in_flight: &Arc<DashSet<String>>,
2741 routing_quota: &mut RoutingTickQuota,
2742) -> anyhow::Result<()> {
2743 let _tick_span = tracing::info_span!("engine.tick").entered();
2744
2745 for (project, call_type, calls) in crate::github::http::drain_call_counts()
2747 .into_iter()
2748 .take(10)
2749 {
2750 tracing::debug!(
2751 project,
2752 call_type,
2753 calls,
2754 "github api calls since last tick"
2755 );
2756 }
2757
2758 startup_cleanup(tmux).await;
2760
2761 if let Err(e) = tick_check_session_completions(tmux, repo, capture, store).await {
2762 tracing::warn!(err = %e, "session completion phase failed");
2763 }
2764 if !store.is_leader() {
2766 return Ok(());
2767 }
2768
2769 if let Err(e) =
2770 tick_detect_silent_agents(tmux, repo, capture, backend, task_manager, config, store).await
2771 {
2772 tracing::warn!(err = %e, "silent-agent detection phase failed");
2773 }
2774 let session_map = tmux.batch_session_active().await;
2777 if let Err(e) = tick_recover_stuck_tasks(
2778 backend,
2779 tmux,
2780 repo,
2781 task_manager,
2782 config,
2783 store,
2784 &session_map,
2785 router,
2786 dispatching,
2787 auto_merge_in_flight,
2788 )
2789 .await
2790 {
2791 tracing::warn!(err = %e, "stuck-task recovery phase failed");
2792 }
2793 if let Err(err) = crate::engine::dependencies::reconcile_dependencies(
2794 backend.as_ref(),
2795 store,
2796 transport.map(|arc| arc.as_ref()),
2797 )
2798 .await
2799 {
2800 tracing::warn!(err = %err, "dependency reconcile failed");
2801 }
2802 if let Err(e) =
2803 tick_route_tasks(backend, task_manager, router, store, repo, routing_quota).await
2804 {
2805 tracing::warn!(err = %e, "routing phase failed");
2806 }
2807 if let Err(e) = route_new_tasks_global(router, store, active_repos, routing_quota).await {
2811 tracing::warn!(err = %e, "global new-task routing sweep failed");
2812 }
2813 let dispatch_mode = dispatch_mode_from_router(router);
2814 if let Err(e) = tick_dispatch_tasks(
2815 backend,
2816 tmux,
2817 repo,
2818 runner,
2819 capture,
2820 semaphore,
2821 task_manager,
2822 weight_tx,
2823 dispatch_mode,
2824 dispatching,
2825 store,
2826 &session_map,
2827 )
2828 .await
2829 {
2830 tracing::warn!(err = %e, "dispatch phase failed");
2831 }
2832 if let Err(e) = dispatch_routed_tasks_global(
2838 tmux,
2839 capture,
2840 semaphore,
2841 weight_tx,
2842 dispatch_mode,
2843 dispatching,
2844 store,
2845 &session_map,
2846 active_repos,
2847 )
2848 .await
2849 {
2850 tracing::warn!(err = %e, "global routed-task dispatch sweep failed");
2851 }
2852 if let Err(e) = tick_unblock_parents(backend, task_manager, store, repo).await {
2853 tracing::warn!(err = %e, "parent-unblock phase failed");
2854 }
2855 if let Err(e) = tick_job_scheduler(jobs_path, backend, Some(store), repo, transport).await {
2856 tracing::error!(?e, "job scheduler tick failed");
2857 }
2858 Ok(())
2859}
2860
2861#[cfg(test)]
2862mod tests {
2863 use super::*;
2864 use crate::backends::{ExternalId, ExternalTask, Mention, Status};
2865 use crate::channels::transport::Transport;
2866 use crate::engine::tasks::TaskManager;
2867 use crate::store::TaskStore;
2868 use crate::tmux::{SessionGuard, TmuxManager};
2869 use async_trait::async_trait;
2870 use std::sync::{Arc, Mutex};
2871
2872 #[test]
2873 fn skips_agent_for_human_and_no_agent_labels() {
2874 assert!(skips_agent(&["owner:human".to_string()]));
2875 assert!(skips_agent(&["no-agent".to_string()]));
2876 assert!(!skips_agent(&["status:new".to_string()]));
2877 }
2878
2879 fn cooled_review_router_for_test() -> Router {
2883 Router::new_for_test(
2884 crate::engine::router::RouterConfig::default(),
2885 vec!["claude".to_string()],
2886 )
2887 }
2888
2889 struct MockBackend {
2893 blocked_tasks: Vec<ExternalTask>,
2895 sub_issues: std::collections::HashMap<String, Vec<ExternalId>>,
2897 tasks_by_id: std::collections::HashMap<String, ExternalTask>,
2899 get_sub_issues_calls: Arc<Mutex<Vec<String>>>,
2901 status_updates: Arc<Mutex<Vec<(String, Status)>>>,
2903 removed_labels: Arc<Mutex<Vec<(String, String)>>>,
2905 posted_comments: Arc<Mutex<Vec<(String, String)>>>,
2907 }
2908
2909 impl MockBackend {
2910 fn new() -> Self {
2911 Self {
2912 blocked_tasks: vec![],
2913 sub_issues: Default::default(),
2914 tasks_by_id: Default::default(),
2915 get_sub_issues_calls: Arc::new(Mutex::new(vec![])),
2916 status_updates: Arc::new(Mutex::new(vec![])),
2917 removed_labels: Arc::new(Mutex::new(vec![])),
2918 posted_comments: Arc::new(Mutex::new(vec![])),
2919 }
2920 }
2921 }
2922
2923 fn make_task(id: &str, labels: &[&str]) -> ExternalTask {
2924 ExternalTask {
2925 id: ExternalId(id.to_string()),
2926 title: format!("Task {id}"),
2927 body: "".to_string(),
2928 state: "open".to_string(),
2929 labels: labels.iter().map(|s| s.to_string()).collect(),
2930 author: "bot".to_string(),
2931 created_at: "2026-01-01T00:00:00Z".to_string(),
2932 updated_at: "2026-01-01T00:00:00Z".to_string(),
2933 url: format!("https://github.com/test/test/issues/{id}"),
2934 }
2935 }
2936
2937 #[async_trait]
2938 impl ExternalBackend for MockBackend {
2939 fn name(&self) -> &str {
2940 "mock"
2941 }
2942 async fn create_task(
2943 &self,
2944 _title: &str,
2945 _body: &str,
2946 _labels: &[String],
2947 ) -> anyhow::Result<ExternalId> {
2948 Ok(ExternalId("new".to_string()))
2949 }
2950 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
2951 self.tasks_by_id
2952 .get(&id.0)
2953 .cloned()
2954 .ok_or_else(|| anyhow::anyhow!("task not found: {}", id.0))
2955 }
2956 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
2957 if status == Status::Blocked {
2958 Ok(self.blocked_tasks.clone())
2959 } else {
2960 Ok(vec![])
2961 }
2962 }
2963 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
2964 Ok(vec![])
2965 }
2966 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
2967 self.posted_comments
2968 .lock()
2969 .unwrap()
2970 .push((_id.0.clone(), _body.to_string()));
2971 Ok(())
2972 }
2973 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
2974 Ok(())
2975 }
2976 async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
2977 self.removed_labels
2978 .lock()
2979 .unwrap()
2980 .push((_id.0.clone(), _label.to_string()));
2981 Ok(())
2982 }
2983 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
2984 self.get_sub_issues_calls.lock().unwrap().push(id.0.clone());
2985 Ok(self.sub_issues.get(&id.0).cloned().unwrap_or_default())
2986 }
2987 async fn create_sub_task(
2988 &self,
2989 _parent: &ExternalId,
2990 _title: &str,
2991 _body: &str,
2992 _labels: &[String],
2993 ) -> anyhow::Result<ExternalId> {
2994 Ok(ExternalId("child".to_string()))
2995 }
2996 async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
2997 Ok(())
2998 }
2999 async fn has_open_issue_with_title(
3000 &self,
3001 _title: &str,
3002 _label: &str,
3003 ) -> anyhow::Result<bool> {
3004 Ok(false)
3005 }
3006 async fn health_check(&self) -> anyhow::Result<()> {
3007 Ok(())
3008 }
3009 async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
3010 Ok(false)
3011 }
3012 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
3013 Ok(Some("testbot".to_string()))
3014 }
3015 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
3016 Ok(vec![])
3017 }
3018 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
3019 self.status_updates
3020 .lock()
3021 .unwrap()
3022 .push((id.0.clone(), status));
3023 Ok(())
3024 }
3025 }
3026
3027 fn make_task_manager(backend: Arc<dyn ExternalBackend>) -> Arc<TaskManager> {
3028 Arc::new(TaskManager::new(backend))
3029 }
3030
3031 #[tokio::test]
3034 async fn unblock_parents_unblocks_when_all_children_done() {
3035 let mut mock = MockBackend::new();
3036
3037 let parent = make_task("10", &["status:blocked"]);
3039 mock.blocked_tasks.push(parent.clone());
3040 mock.sub_issues.insert(
3041 "10".to_string(),
3042 vec![ExternalId("11".to_string()), ExternalId("12".to_string())],
3043 );
3044 mock.tasks_by_id
3045 .insert("11".to_string(), make_task("11", &["status:done"]));
3046 mock.tasks_by_id
3047 .insert("12".to_string(), make_task("12", &["status:done"]));
3048
3049 let status_updates = mock.status_updates.clone();
3050 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3051 let task_manager = make_task_manager(backend.clone());
3052 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3053
3054 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3055 .await
3056 .unwrap();
3057
3058 let updates = status_updates.lock().unwrap();
3059 assert_eq!(updates.len(), 1, "parent should be unblocked");
3060 assert_eq!(updates[0], ("10".to_string(), Status::New));
3061 }
3062
3063 #[tokio::test]
3064 async fn unblock_parents_skips_when_child_not_done() {
3065 let mut mock = MockBackend::new();
3066
3067 let parent = make_task("20", &["status:blocked"]);
3068 mock.blocked_tasks.push(parent.clone());
3069 mock.sub_issues.insert(
3070 "20".to_string(),
3071 vec![ExternalId("21".to_string()), ExternalId("22".to_string())],
3072 );
3073 mock.tasks_by_id
3075 .insert("21".to_string(), make_task("21", &["status:done"]));
3076 mock.tasks_by_id
3077 .insert("22".to_string(), make_task("22", &["status:in_progress"]));
3078
3079 let status_updates = mock.status_updates.clone();
3080 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3081 let task_manager = make_task_manager(backend.clone());
3082 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3083
3084 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3085 .await
3086 .unwrap();
3087
3088 let updates = status_updates.lock().unwrap();
3089 assert!(
3090 updates.is_empty(),
3091 "should not unblock when a child is still running"
3092 );
3093 }
3094
3095 #[tokio::test]
3096 async fn unblock_parents_skips_task_with_no_children() {
3097 let mut mock = MockBackend::new();
3098
3099 let parent = make_task("30", &["status:blocked"]);
3101 mock.blocked_tasks.push(parent.clone());
3102 let status_updates = mock.status_updates.clone();
3105 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3106 let task_manager = make_task_manager(backend.clone());
3107 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3108
3109 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3110 .await
3111 .unwrap();
3112
3113 let updates = status_updates.lock().unwrap();
3114 assert!(
3115 updates.is_empty(),
3116 "should not unblock tasks with no sub-issues"
3117 );
3118 }
3119
3120 #[tokio::test]
3121 async fn unblock_parents_skips_task_with_block_reason_in_store() {
3122 let mut mock = MockBackend::new();
3125
3126 let parent = make_task("60", &["status:blocked"]);
3128 mock.blocked_tasks.push(parent.clone());
3129 mock.sub_issues
3132 .insert("60".to_string(), vec![ExternalId("61".to_string())]);
3133 mock.tasks_by_id
3134 .insert("61".to_string(), make_task("61", &["status:done"]));
3135
3136 let status_updates = mock.status_updates.clone();
3137 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3138 let task_manager = make_task_manager(backend.clone());
3139
3140 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3142 let task_id = store
3143 .upsert_external(&crate::store::UpsertExternal {
3144 repo: "test/repo",
3145 ext_id: "60",
3146 title: "Task 60",
3147 body: "",
3148 author: "bot",
3149 url: "https://github.com/test/test/issues/60",
3150 labels: &[],
3151 origin: "github",
3152 })
3153 .await
3154 .unwrap();
3155 store
3156 .update_status(task_id, crate::store::TaskStatus::Blocked)
3157 .await
3158 .unwrap();
3159 store
3160 .set_block_reason(task_id, Some("CI failure limit reached"))
3161 .await
3162 .unwrap();
3163
3164 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3165 .await
3166 .unwrap();
3167
3168 let updates = status_updates.lock().unwrap();
3169 assert!(
3170 updates.is_empty(),
3171 "task with block_reason should be skipped — no API call, no unblock"
3172 );
3173 }
3174
3175 #[tokio::test]
3176 async fn unblock_parents_unblocks_internal_parent_from_store_children() {
3177 let mock = MockBackend::new();
3178 let get_sub_issues_calls = mock.get_sub_issues_calls.clone();
3179 let status_updates = mock.status_updates.clone();
3180 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3181 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3182 let task_manager = Arc::new(TaskManager::with_store(
3183 backend.clone(),
3184 store.clone(),
3185 "test/repo".to_string(),
3186 ));
3187
3188 let parent_id = store
3189 .create_internal("test/repo", "Parent", "", "job", "daily", None)
3190 .await
3191 .unwrap();
3192 store
3193 .update_status(parent_id, crate::store::TaskStatus::Blocked)
3194 .await
3195 .unwrap();
3196 let child_id = store
3197 .create(&crate::store::NewTask {
3198 external_id: Some("91".to_string()),
3199 repo: "test/repo".to_string(),
3200 origin: "github".to_string(),
3201 title: "Child".to_string(),
3202 body: String::new(),
3203 source: String::new(),
3204 source_id: String::new(),
3205 author: String::new(),
3206 url: String::new(),
3207 labels: vec![],
3208 parent_id: Some(parent_id),
3209 })
3210 .await
3211 .unwrap();
3212 store
3213 .update_status(child_id, crate::store::TaskStatus::Done)
3214 .await
3215 .unwrap();
3216
3217 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
3218 .await
3219 .unwrap();
3220
3221 let parent = store.get(parent_id).await.unwrap();
3222 assert_eq!(parent.status, crate::store::TaskStatus::New);
3223 assert!(
3224 status_updates.lock().unwrap().is_empty(),
3225 "internal parent should be updated in store without backend mirroring"
3226 );
3227 assert!(
3228 get_sub_issues_calls.lock().unwrap().is_empty(),
3229 "internal parent should not query backend sub-issues"
3230 );
3231 }
3232
3233 #[cfg(test)]
3237 async fn set_task_updated_at_past(store: &TaskStore, task_id: i64) {
3238 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
3239 .bind(task_id)
3240 .execute(store.pool())
3241 .await
3242 .unwrap();
3243 }
3244
3245 async fn set_in_review_entered_at_past(store: &TaskStore, task_id: i64) {
3247 sqlx::query(
3248 "UPDATE task_activity SET timestamp = '2020-01-01T00:00:00Z'
3249 WHERE task_id = ? AND to_status = 'in_review'",
3250 )
3251 .bind(task_id)
3252 .execute(store.pool())
3253 .await
3254 .unwrap();
3255 }
3256
3257 #[tokio::test]
3258 async fn recover_stuck_tasks_resets_external_in_review_to_needs_review() {
3259 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3260 let mock = MockBackend::new();
3261 let status_updates = mock.status_updates.clone();
3262 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3263 let task_manager = Arc::new(TaskManager::with_store(
3264 backend.clone(),
3265 store.clone(),
3266 "owner/repo".to_string(),
3267 ));
3268 let tmux = Arc::new(TmuxManager::new());
3269 let config = EngineConfig {
3270 no_session_stuck_timeout: 600,
3271 stuck_timeout: 1800,
3272 ..EngineConfig::default()
3273 };
3274
3275 let id = store
3277 .upsert_external(&crate::store::UpsertExternal {
3278 repo: "owner/repo",
3279 ext_id: "99",
3280 title: "Stuck InReview",
3281 body: "",
3282 author: "",
3283 url: "",
3284 labels: &[],
3285 origin: "github",
3286 })
3287 .await
3288 .unwrap();
3289 store
3290 .update_status(id, crate::store::TaskStatus::InReview)
3291 .await
3292 .unwrap();
3293 set_review_session_expected(&store, "owner/repo", "99", true).await;
3294 set_in_review_entered_at_past(&store, id).await;
3295 set_task_updated_at_past(&store, id).await;
3296
3297 tick_recover_stuck_tasks(
3298 &backend,
3299 &tmux,
3300 "owner/repo",
3301 &task_manager,
3302 &config,
3303 &store,
3304 &std::collections::HashMap::new(),
3305 &cooled_review_router_for_test(),
3306 &Arc::new(DashMap::new()),
3307 &Arc::new(DashSet::new()),
3308 )
3309 .await
3310 .unwrap();
3311
3312 let updates = status_updates.lock().unwrap();
3313 assert_eq!(updates.len(), 1, "stuck InReview task should be recovered");
3314 assert_eq!(updates[0].0, "99");
3315 assert_eq!(updates[0].1, Status::NeedsReview);
3316 }
3317
3318 #[tokio::test]
3319 async fn recover_stuck_tasks_skips_recent_in_review() {
3320 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3321 let mock = MockBackend::new();
3322 let status_updates = mock.status_updates.clone();
3323 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3324 let task_manager = Arc::new(TaskManager::with_store(
3325 backend.clone(),
3326 store.clone(),
3327 "owner/repo".to_string(),
3328 ));
3329 let tmux = Arc::new(TmuxManager::new());
3330 let config = EngineConfig {
3331 no_session_stuck_timeout: 600, stuck_timeout: 1800,
3333 ..EngineConfig::default()
3334 };
3335
3336 let id = store
3338 .upsert_external(&crate::store::UpsertExternal {
3339 repo: "owner/repo",
3340 ext_id: "100",
3341 title: "Recent InReview",
3342 body: "",
3343 author: "",
3344 url: "",
3345 labels: &[],
3346 origin: "github",
3347 })
3348 .await
3349 .unwrap();
3350 store
3351 .update_status(id, crate::store::TaskStatus::InReview)
3352 .await
3353 .unwrap();
3354 set_review_session_expected(&store, "owner/repo", "100", true).await;
3355 set_task_updated_at_past(&store, id).await;
3356
3357 tick_recover_stuck_tasks(
3358 &backend,
3359 &tmux,
3360 "owner/repo",
3361 &task_manager,
3362 &config,
3363 &store,
3364 &std::collections::HashMap::new(),
3365 &cooled_review_router_for_test(),
3366 &Arc::new(DashMap::new()),
3367 &Arc::new(DashSet::new()),
3368 )
3369 .await
3370 .unwrap();
3371
3372 let updates = status_updates.lock().unwrap();
3373 assert!(
3374 updates.is_empty(),
3375 "recent InReview task should not be recovered"
3376 );
3377 }
3378
3379 #[tokio::test]
3380 async fn recover_stuck_tasks_resets_internal_in_review_to_needs_review() {
3381 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3382 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
3383 let task_manager = Arc::new(TaskManager::with_store(
3384 backend.clone(),
3385 store.clone(),
3386 "owner/repo".to_string(),
3387 ));
3388 let tmux = Arc::new(TmuxManager::new());
3389 let config = EngineConfig {
3390 no_session_stuck_timeout: 600,
3391 stuck_timeout: 1800,
3392 ..EngineConfig::default()
3393 };
3394
3395 let id = store
3397 .create_internal("owner/repo", "Internal InReview", "", "cron", "1", None)
3398 .await
3399 .unwrap();
3400 store
3401 .update_status(id, crate::store::TaskStatus::InReview)
3402 .await
3403 .unwrap();
3404 set_review_session_expected(&store, "owner/repo", "internal:1", true).await;
3405 set_in_review_entered_at_past(&store, id).await;
3406 set_task_updated_at_past(&store, id).await;
3407
3408 tick_recover_stuck_tasks(
3409 &backend,
3410 &tmux,
3411 "owner/repo",
3412 &task_manager,
3413 &config,
3414 &store,
3415 &std::collections::HashMap::new(),
3416 &cooled_review_router_for_test(),
3417 &Arc::new(DashMap::new()),
3418 &Arc::new(DashSet::new()),
3419 )
3420 .await
3421 .unwrap();
3422
3423 let task = store.get(id).await.unwrap();
3424 assert_eq!(
3425 task.status,
3426 crate::store::TaskStatus::NeedsReview,
3427 "stuck internal InReview task should be reset to NeedsReview"
3428 );
3429 }
3430
3431 #[tokio::test]
3432 async fn recover_stuck_tasks_ages_in_review_from_status_entry_not_updated_at() {
3433 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3434 let mock = MockBackend::new();
3435 let status_updates = mock.status_updates.clone();
3436 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3437 let task_manager = Arc::new(TaskManager::with_store(
3438 backend.clone(),
3439 store.clone(),
3440 "owner/repo".to_string(),
3441 ));
3442 let tmux = Arc::new(TmuxManager::new());
3443 let config = EngineConfig {
3444 no_session_stuck_timeout: 600,
3445 stuck_timeout: 1800,
3446 ..EngineConfig::default()
3447 };
3448
3449 let id = store
3450 .upsert_external(&crate::store::UpsertExternal {
3451 repo: "owner/repo",
3452 ext_id: "102",
3453 title: "Fresh updated_at InReview",
3454 body: "",
3455 author: "",
3456 url: "",
3457 labels: &[],
3458 origin: "github",
3459 })
3460 .await
3461 .unwrap();
3462 store
3463 .update_status(id, crate::store::TaskStatus::InReview)
3464 .await
3465 .unwrap();
3466 set_review_session_expected(&store, "owner/repo", "102", true).await;
3467 set_in_review_entered_at_past(&store, id).await;
3468 store
3469 .set_fields(id, &[("pr_number", serde_json::json!(42))])
3470 .await
3471 .unwrap();
3472
3473 tick_recover_stuck_tasks(
3474 &backend,
3475 &tmux,
3476 "owner/repo",
3477 &task_manager,
3478 &config,
3479 &store,
3480 &std::collections::HashMap::new(),
3481 &cooled_review_router_for_test(),
3482 &Arc::new(DashMap::new()),
3483 &Arc::new(DashSet::new()),
3484 )
3485 .await
3486 .unwrap();
3487
3488 let updates = status_updates.lock().unwrap();
3489 assert_eq!(
3490 updates.len(),
3491 1,
3492 "in_review stuck recovery must age from the status entry even when updated_at is fresh"
3493 );
3494 assert_eq!(updates[0].0, "102");
3495 assert_eq!(updates[0].1, Status::NeedsReview);
3496 }
3497
3498 #[tokio::test]
3499 async fn recover_stuck_tasks_reclaims_hung_in_review_session() {
3500 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3501 let mock = MockBackend::new();
3502 let status_updates = mock.status_updates.clone();
3503 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3504 let task_manager = Arc::new(TaskManager::with_store(
3505 backend.clone(),
3506 store.clone(),
3507 "owner/repo".to_string(),
3508 ));
3509 let tmux = Arc::new(TmuxManager::new());
3510 let config = EngineConfig {
3511 no_session_stuck_timeout: 600,
3512 stuck_timeout: 1800,
3513 ..EngineConfig::default()
3514 };
3515
3516 let id = store
3517 .upsert_external(&crate::store::UpsertExternal {
3518 repo: "owner/repo",
3519 ext_id: "103",
3520 title: "Hung review session",
3521 body: "",
3522 author: "",
3523 url: "",
3524 labels: &[],
3525 origin: "github",
3526 })
3527 .await
3528 .unwrap();
3529 store
3530 .update_status(id, crate::store::TaskStatus::InReview)
3531 .await
3532 .unwrap();
3533 set_review_session_expected(&store, "owner/repo", "103", true).await;
3534 set_in_review_entered_at_past(&store, id).await;
3535 store
3536 .set_fields(id, &[("pr_number", serde_json::json!(43))])
3537 .await
3538 .unwrap();
3539
3540 let session_name = tmux.session_name("owner/repo", "103-review");
3541 let mut session_map = std::collections::HashMap::new();
3542 session_map.insert(session_name, true);
3543
3544 tick_recover_stuck_tasks(
3545 &backend,
3546 &tmux,
3547 "owner/repo",
3548 &task_manager,
3549 &config,
3550 &store,
3551 &session_map,
3552 &cooled_review_router_for_test(),
3553 &Arc::new(DashMap::new()),
3554 &Arc::new(DashSet::new()),
3555 )
3556 .await
3557 .unwrap();
3558
3559 let updates = status_updates.lock().unwrap();
3560 assert_eq!(
3561 updates.len(),
3562 1,
3563 "a live hung review session must be reclaimed once the in_review entry ages past stuck_timeout"
3564 );
3565 assert_eq!(updates[0].0, "103");
3566 assert_eq!(updates[0].1, Status::NeedsReview);
3567 }
3568
3569 #[tokio::test]
3570 async fn recover_stuck_tasks_resets_cross_repo_external_in_progress_to_new() {
3571 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3572 let mock = MockBackend::new();
3573 let status_updates = mock.status_updates.clone();
3574 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
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 .upsert_external(&crate::store::UpsertExternal {
3590 repo: "owner/other",
3591 ext_id: "777",
3592 title: "Cross-repo stuck task",
3593 body: "",
3594 author: "",
3595 url: "",
3596 labels: &[],
3597 origin: "github",
3598 })
3599 .await
3600 .unwrap();
3601 store
3602 .update_status(id, crate::store::TaskStatus::InProgress)
3603 .await
3604 .unwrap();
3605 set_task_updated_at_past(&store, id).await;
3606
3607 tick_recover_stuck_tasks(
3608 &backend,
3609 &tmux,
3610 "owner/repo",
3611 &task_manager,
3612 &config,
3613 &store,
3614 &std::collections::HashMap::new(),
3615 &cooled_review_router_for_test(),
3616 &Arc::new(DashMap::new()),
3617 &Arc::new(DashSet::new()),
3618 )
3619 .await
3620 .unwrap();
3621
3622 let task = store.get(id).await.unwrap();
3623 assert_eq!(
3624 task.status,
3625 crate::store::TaskStatus::New,
3626 "cross-repo stale in_progress task should be reset to New"
3627 );
3628 assert_eq!(task.route_attempts, 0, "route attempts should be cleared");
3629 assert!(task.agent.is_none(), "agent should be cleared");
3630 assert!(task.model.is_none(), "model should be cleared");
3631
3632 assert!(
3634 status_updates.lock().unwrap().is_empty(),
3635 "cross-repo recovery should not call backend update_status"
3636 );
3637 }
3638
3639 #[tokio::test]
3640 async fn recover_stuck_tasks_finalizes_open_task_runs_rows() {
3641 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3642 let mock = MockBackend::new();
3643 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3644 let task_manager = Arc::new(TaskManager::with_store(
3645 backend.clone(),
3646 store.clone(),
3647 "owner/repo".to_string(),
3648 ));
3649 let tmux = Arc::new(TmuxManager::new());
3650 let config = EngineConfig {
3651 no_session_stuck_timeout: 600,
3652 stuck_timeout: 1800,
3653 ..EngineConfig::default()
3654 };
3655
3656 let id = store
3657 .create_internal("owner/repo", "Stuck with open run", "", "cron", "1", None)
3658 .await
3659 .unwrap();
3660 store
3661 .set_fields(
3662 id,
3663 &[
3664 ("agent", serde_json::json!("claude")),
3665 ("model", serde_json::json!("sonnet")),
3666 ("route_attempts", serde_json::json!(1)),
3667 ],
3668 )
3669 .await
3670 .unwrap();
3671 store
3672 .update_status(id, crate::store::TaskStatus::InProgress)
3673 .await
3674 .unwrap();
3675 set_task_updated_at_past(&store, id).await;
3676
3677 store
3679 .start_run(&crate::store::StartRun {
3680 task_id: id,
3681 attempt: 1,
3682 run_type: "agent",
3683 agent: "claude",
3684 model: "sonnet",
3685 command: "claude --model sonnet",
3686 prompt: "fix it",
3687 })
3688 .await
3689 .unwrap();
3690
3691 tick_recover_stuck_tasks(
3692 &backend,
3693 &tmux,
3694 "owner/repo",
3695 &task_manager,
3696 &config,
3697 &store,
3698 &std::collections::HashMap::new(),
3699 &cooled_review_router_for_test(),
3700 &Arc::new(DashMap::new()),
3701 &Arc::new(DashSet::new()),
3702 )
3703 .await
3704 .unwrap();
3705
3706 let task = store.get(id).await.unwrap();
3707 assert_eq!(task.status, crate::store::TaskStatus::New);
3708
3709 let runs = store.get_runs(id).await.unwrap();
3710 assert_eq!(runs.len(), 1);
3711 assert_eq!(runs[0].outcome, "aborted");
3712 assert!(runs[0].completed_at.is_some());
3713 assert_eq!(
3714 runs[0].error,
3715 "stuck-task recovery: internal no session found"
3716 );
3717 }
3718
3719 #[tokio::test]
3736 async fn recover_stuck_tasks_skips_reclaim_while_dispatch_guard_held() {
3737 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3738 let mock = MockBackend::new();
3739 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3740 let task_manager = Arc::new(TaskManager::with_store(
3741 backend.clone(),
3742 store.clone(),
3743 "owner/repo".to_string(),
3744 ));
3745 let tmux = Arc::new(TmuxManager::new());
3746 let config = EngineConfig {
3747 no_session_stuck_timeout: 600,
3748 stuck_timeout: 1800,
3749 ..EngineConfig::default()
3750 };
3751
3752 let id = store
3753 .create_internal(
3754 "owner/repo",
3755 "Long-running daily job",
3756 "",
3757 "cron",
3758 "1",
3759 None,
3760 )
3761 .await
3762 .unwrap();
3763 store
3764 .set_fields(
3765 id,
3766 &[
3767 ("agent", serde_json::json!("claude")),
3768 ("model", serde_json::json!("sonnet")),
3769 ("route_attempts", serde_json::json!(1)),
3770 ],
3771 )
3772 .await
3773 .unwrap();
3774 store
3775 .update_status(id, crate::store::TaskStatus::InProgress)
3776 .await
3777 .unwrap();
3778 set_task_updated_at_past(&store, id).await;
3781
3782 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
3785 dispatching.insert(
3786 format!("owner/repo/internal:{id}"),
3787 format!("internal:{id}"),
3788 );
3789
3790 tick_recover_stuck_tasks(
3791 &backend,
3792 &tmux,
3793 "owner/repo",
3794 &task_manager,
3795 &config,
3796 &store,
3797 &std::collections::HashMap::new(),
3798 &cooled_review_router_for_test(),
3799 &dispatching,
3800 &Arc::new(DashSet::new()),
3801 )
3802 .await
3803 .unwrap();
3804
3805 let task = store.get(id).await.unwrap();
3806 assert_eq!(
3807 task.status,
3808 crate::store::TaskStatus::InProgress,
3809 "task should not be reclaimed while its dispatch guard is still held"
3810 );
3811
3812 let runs = store.get_runs(id).await.unwrap();
3814 assert!(runs.is_empty());
3815
3816 dispatching.clear();
3819 tick_recover_stuck_tasks(
3820 &backend,
3821 &tmux,
3822 "owner/repo",
3823 &task_manager,
3824 &config,
3825 &store,
3826 &std::collections::HashMap::new(),
3827 &cooled_review_router_for_test(),
3828 &dispatching,
3829 &Arc::new(DashSet::new()),
3830 )
3831 .await
3832 .unwrap();
3833 let task = store.get(id).await.unwrap();
3834 assert_eq!(
3835 task.status,
3836 crate::store::TaskStatus::New,
3837 "task should still be reclaimed once the dispatch guard is released"
3838 );
3839 }
3840
3841 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
3857 async fn recover_stuck_tasks_never_reclaims_while_guard_held_under_real_concurrency() {
3858 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3859 let mock = MockBackend::new();
3860 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
3861 let task_manager = Arc::new(TaskManager::with_store(
3862 backend.clone(),
3863 store.clone(),
3864 "owner/repo".to_string(),
3865 ));
3866 let tmux = Arc::new(TmuxManager::new());
3867 let config = EngineConfig {
3868 no_session_stuck_timeout: 600,
3869 stuck_timeout: 1800,
3870 ..EngineConfig::default()
3871 };
3872
3873 let id = store
3874 .create_internal(
3875 "owner/repo",
3876 "Long-running daily job",
3877 "",
3878 "cron",
3879 "1",
3880 None,
3881 )
3882 .await
3883 .unwrap();
3884 store
3885 .set_fields(
3886 id,
3887 &[
3888 ("agent", serde_json::json!("claude")),
3889 ("model", serde_json::json!("sonnet")),
3890 ("route_attempts", serde_json::json!(1)),
3891 ],
3892 )
3893 .await
3894 .unwrap();
3895 store
3896 .update_status(id, crate::store::TaskStatus::InProgress)
3897 .await
3898 .unwrap();
3899 set_task_updated_at_past(&store, id).await;
3900
3901 let dispatch_key = format!("owner/repo/internal:{id}");
3902 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
3903
3904 let claim_task_id = format!("internal:{id}");
3911 {
3912 use dashmap::mapref::entry::Entry;
3913 match dispatching.entry(dispatch_key.clone()) {
3914 Entry::Vacant(slot) => {
3915 slot.insert(claim_task_id);
3916 }
3917 Entry::Occupied(_) => panic!("dispatch key already claimed"),
3918 }
3919 }
3920 let guard = DispatchGuard::new(Arc::clone(&dispatching), dispatch_key.clone());
3921
3922 let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
3933 let holder = tokio::spawn(async move {
3934 let _ = release_rx.await;
3935 drop(guard);
3936 });
3937
3938 const HAMMER_ITERATIONS: u32 = 300;
3946 for i in 0..HAMMER_ITERATIONS {
3947 tick_recover_stuck_tasks(
3948 &backend,
3949 &tmux,
3950 "owner/repo",
3951 &task_manager,
3952 &config,
3953 &store,
3954 &std::collections::HashMap::new(),
3955 &cooled_review_router_for_test(),
3956 &dispatching,
3957 &Arc::new(DashSet::new()),
3958 )
3959 .await
3960 .unwrap();
3961
3962 let task = store.get(id).await.unwrap();
3963 assert_eq!(
3964 task.status,
3965 crate::store::TaskStatus::InProgress,
3966 "task must never be reclaimed while the dispatch guard is deterministically \
3967 still held, even under real cross-thread concurrency (dispatch_key={dispatch_key}, \
3968 iteration={i}, contains_key_now={}, map_len_now={})",
3969 dispatching.contains_key(&dispatch_key),
3970 dispatching.len(),
3971 );
3972 tokio::task::yield_now().await;
3973 }
3974
3975 let _ = release_tx.send(());
3977 holder.await.unwrap();
3978
3979 tick_recover_stuck_tasks(
3981 &backend,
3982 &tmux,
3983 "owner/repo",
3984 &task_manager,
3985 &config,
3986 &store,
3987 &std::collections::HashMap::new(),
3988 &cooled_review_router_for_test(),
3989 &dispatching,
3990 &Arc::new(DashSet::new()),
3991 )
3992 .await
3993 .unwrap();
3994 let task = store.get(id).await.unwrap();
3995 assert_eq!(
3996 task.status,
3997 crate::store::TaskStatus::New,
3998 "task should be reclaimed once the dispatch guard is released"
3999 );
4000 }
4001
4002 #[tokio::test]
4003 async fn recover_stuck_tasks_skips_external_in_review_waiting_for_human_review() {
4004 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4005 let mock = MockBackend::new();
4006 let status_updates = mock.status_updates.clone();
4007 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4008 let task_manager = Arc::new(TaskManager::with_store(
4009 backend.clone(),
4010 store.clone(),
4011 "owner/repo".to_string(),
4012 ));
4013 let tmux = Arc::new(TmuxManager::new());
4014 let config = EngineConfig {
4015 no_session_stuck_timeout: 600,
4016 stuck_timeout: 1800,
4017 ..EngineConfig::default()
4018 };
4019
4020 let id = store
4021 .upsert_external(&crate::store::UpsertExternal {
4022 repo: "owner/repo",
4023 ext_id: "101",
4024 title: "Waiting For Human Review",
4025 body: "",
4026 author: "",
4027 url: "",
4028 labels: &[],
4029 origin: "github",
4030 })
4031 .await
4032 .unwrap();
4033 store
4034 .update_status(id, crate::store::TaskStatus::InReview)
4035 .await
4036 .unwrap();
4037 set_task_updated_at_past(&store, id).await;
4038
4039 tick_recover_stuck_tasks(
4040 &backend,
4041 &tmux,
4042 "owner/repo",
4043 &task_manager,
4044 &config,
4045 &store,
4046 &std::collections::HashMap::new(),
4047 &cooled_review_router_for_test(),
4048 &Arc::new(DashMap::new()),
4049 &Arc::new(DashSet::new()),
4050 )
4051 .await
4052 .unwrap();
4053
4054 let updates = status_updates.lock().unwrap();
4055 assert!(
4056 updates.is_empty(),
4057 "InReview task waiting for human review should not be reset"
4058 );
4059 }
4060
4061 #[tokio::test]
4062 async fn unblock_parents_no_blocked_tasks() {
4063 let mock = MockBackend::new(); let status_updates = mock.status_updates.clone();
4065 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4066 let task_manager = make_task_manager(backend.clone());
4067 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4068
4069 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
4070 .await
4071 .unwrap();
4072
4073 assert!(status_updates.lock().unwrap().is_empty());
4074 }
4075
4076 #[tokio::test]
4077 async fn unblock_parents_handles_multiple_blocked_tasks() {
4078 let mut mock = MockBackend::new();
4079
4080 let t40 = make_task("40", &["status:blocked"]);
4082 mock.blocked_tasks.push(t40);
4083 mock.sub_issues
4084 .insert("40".to_string(), vec![ExternalId("41".to_string())]);
4085 mock.tasks_by_id
4086 .insert("41".to_string(), make_task("41", &["status:done"]));
4087
4088 let t50 = make_task("50", &["status:blocked"]);
4090 mock.blocked_tasks.push(t50);
4091 mock.sub_issues
4092 .insert("50".to_string(), vec![ExternalId("51".to_string())]);
4093 mock.tasks_by_id
4094 .insert("51".to_string(), make_task("51", &["status:in_progress"]));
4095
4096 let status_updates = mock.status_updates.clone();
4097 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4098 let task_manager = make_task_manager(backend.clone());
4099 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4100
4101 tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
4102 .await
4103 .unwrap();
4104
4105 let updates = status_updates.lock().unwrap();
4106 assert_eq!(updates.len(), 1, "only one parent should be unblocked");
4107 assert_eq!(updates[0].0, "40", "task 40 should be unblocked");
4108 assert_eq!(updates[0].1, Status::New);
4109 }
4110
4111 #[tokio::test]
4112 async fn detect_silent_agents_skips_backend_for_internal_tasks() {
4113 let transport = Arc::new(Transport::new());
4114 let capture = Arc::new(CaptureService::new(transport));
4115 let mock = Arc::new(MockBackend::new());
4116 let backend: Arc<dyn ExternalBackend> = mock.clone();
4117 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4118 let task_manager = Arc::new(TaskManager::with_store(
4119 backend.clone(),
4120 store.clone(),
4121 "owner/repo".to_string(),
4122 ));
4123 let tmux = Arc::new(TmuxManager::new());
4124 let config = EngineConfig {
4125 silence_grace_period: 0,
4126 ..EngineConfig::default()
4127 };
4128
4129 let internal_id = store
4130 .create_internal("owner/repo", "Silent internal", "", "cron", "1", None)
4131 .await
4132 .unwrap();
4133 store
4134 .update_status(internal_id, crate::store::TaskStatus::InProgress)
4135 .await
4136 .unwrap();
4137
4138 let task_id = format!("internal:{internal_id}");
4139 capture
4140 .register_session("owner/repo", &task_id, "orch-test-internal")
4141 .await;
4142 tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
4143
4144 tick_detect_silent_agents(
4145 &tmux,
4146 "owner/repo",
4147 &capture,
4148 &backend,
4149 &task_manager,
4150 &config,
4151 &store,
4152 )
4153 .await
4154 .unwrap();
4155
4156 assert!(
4157 mock.removed_labels.lock().unwrap().is_empty(),
4158 "internal tasks should skip backend label removal"
4159 );
4160 assert!(
4161 mock.posted_comments.lock().unwrap().is_empty(),
4162 "internal tasks should skip backend comments"
4163 );
4164
4165 let task = store.get(internal_id).await.unwrap();
4166 assert!(
4170 matches!(
4171 task.status,
4172 crate::store::TaskStatus::Routed | crate::store::TaskStatus::New
4173 ),
4174 "internal task should be routed to fallback or reset to new, got {:?}",
4175 task.status
4176 );
4177 }
4178
4179 #[tokio::test]
4180 async fn detect_silent_agents_finalizes_open_task_runs_rows() {
4181 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4182 let mock = MockBackend::new();
4183 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4184 let task_manager = Arc::new(TaskManager::with_store(
4185 backend.clone(),
4186 store.clone(),
4187 "owner/repo".to_string(),
4188 ));
4189 let transport = Arc::new(Transport::new());
4190 let capture = Arc::new(CaptureService::new(transport));
4191 let tmux = Arc::new(TmuxManager::new());
4192 let config = EngineConfig {
4193 silence_grace_period: 60,
4194 silence_cooldown: 300,
4195 ..EngineConfig::default()
4196 };
4197
4198 let internal_id = store
4199 .create_internal("owner/repo", "Silent with open run", "", "cron", "1", None)
4200 .await
4201 .unwrap();
4202 store
4203 .set_fields(
4204 internal_id,
4205 &[
4206 ("agent", serde_json::json!("claude")),
4207 ("model", serde_json::json!("sonnet")),
4208 ],
4209 )
4210 .await
4211 .unwrap();
4212 store
4213 .update_status(internal_id, crate::store::TaskStatus::InProgress)
4214 .await
4215 .unwrap();
4216
4217 let task_id = format!("internal:{internal_id}");
4218 capture
4219 .register_session("owner/repo", &task_id, "orch-test-silent-run")
4220 .await;
4221 capture
4222 .set_buffer_state_for_test(
4223 "owner/repo",
4224 &task_id,
4225 false,
4226 false,
4227 chrono::Utc::now() - chrono::Duration::seconds(120),
4228 )
4229 .await;
4230
4231 store
4233 .start_run(&crate::store::StartRun {
4234 task_id: internal_id,
4235 attempt: 1,
4236 run_type: "agent",
4237 agent: "claude",
4238 model: "sonnet",
4239 command: "claude --model sonnet",
4240 prompt: "fix it",
4241 })
4242 .await
4243 .unwrap();
4244
4245 tick_detect_silent_agents(
4246 &tmux,
4247 "owner/repo",
4248 &capture,
4249 &backend,
4250 &task_manager,
4251 &config,
4252 &store,
4253 )
4254 .await
4255 .unwrap();
4256
4257 let runs = store.get_runs(internal_id).await.unwrap();
4258 assert_eq!(runs.len(), 1);
4259 assert_eq!(runs[0].outcome, "aborted");
4260 assert!(runs[0].completed_at.is_some());
4261 assert_eq!(runs[0].error, "silence detection: session killed");
4262 }
4263
4264 #[tokio::test]
4275 async fn phase1_session_completion_touch_prevents_phase2_reclaim() {
4276 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4277 let mock = MockBackend::new();
4278 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4279 let task_manager = Arc::new(TaskManager::with_store(
4280 backend.clone(),
4281 store.clone(),
4282 "owner/repo".to_string(),
4283 ));
4284 let tmux = Arc::new(TmuxManager::new());
4285 let config = EngineConfig {
4286 no_session_stuck_timeout: 600,
4287 stuck_timeout: 1800,
4288 ..EngineConfig::default()
4289 };
4290
4291 let id = store
4295 .create_internal("owner/repo", "Completed task", "", "cron", "1", None)
4296 .await
4297 .unwrap();
4298 store
4299 .update_status(id, crate::store::TaskStatus::InProgress)
4300 .await
4301 .unwrap();
4302 set_task_updated_at_past(&store, id).await;
4303
4304 let task_id = format!("internal:{id}");
4307 store_touch_updated_at(&Some(Arc::clone(&store)), "owner/repo", &task_id).await;
4308
4309 tick_recover_stuck_tasks(
4311 &backend,
4312 &tmux,
4313 "owner/repo",
4314 &task_manager,
4315 &config,
4316 &store,
4317 &std::collections::HashMap::new(),
4318 &cooled_review_router_for_test(),
4319 &Arc::new(DashMap::new()),
4320 &Arc::new(DashSet::new()),
4321 )
4322 .await
4323 .unwrap();
4324
4325 let task = store.get(id).await.unwrap();
4326 assert_eq!(
4327 task.status,
4328 crate::store::TaskStatus::InProgress,
4329 "Phase 2 must not reclaim a task whose updated_at was just touched by Phase 1"
4330 );
4331 }
4332
4333 #[tokio::test]
4334 async fn stuck_task_timing_treats_dead_session_as_missing() {
4335 let tmux = Arc::new(TmuxManager::new());
4336 let repo = "owner/repo";
4337 let task_id = "internal:33498";
4338 let session_name = tmux.session_name(repo, task_id);
4339
4340 let create_result = tokio::process::Command::new("tmux")
4341 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
4342 .output()
4343 .await;
4344
4345 match create_result {
4346 Ok(o) if o.status.success() => {}
4347 _ => {
4348 eprintln!("Skipping test: tmux not available or failed to create test session");
4349 return;
4350 }
4351 }
4352
4353 let _guard = SessionGuard(session_name.clone());
4354
4355 let set_option_result = tokio::process::Command::new("tmux")
4356 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
4357 .output()
4358 .await;
4359 if !matches!(set_option_result, Ok(ref o) if o.status.success()) {
4360 eprintln!("Skipping test: unable to set tmux remain-on-exit option");
4361 return;
4362 }
4363
4364 let send_exit_result = tokio::process::Command::new("tmux")
4365 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
4366 .output()
4367 .await;
4368 if !matches!(send_exit_result, Ok(ref o) if o.status.success()) {
4369 eprintln!("Skipping test: unable to exit tmux pane");
4370 return;
4371 }
4372
4373 for _ in 0..20 {
4375 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4376 if !tmux.session_is_running(&session_name).await {
4377 break;
4378 }
4379 }
4380
4381 let mut session_map = tmux.batch_session_active().await;
4386 for _ in 0..20 {
4387 if !session_map.get(&session_name).copied().unwrap_or(false) {
4388 break;
4389 }
4390 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4391 session_map = tmux.batch_session_active().await;
4392 }
4393 if session_map.get(&session_name).copied().unwrap_or(false) {
4394 eprintln!("Skipping test: session still alive in batch_session_active after retries (CI tmux lag)");
4395 return;
4396 }
4397
4398 let config = EngineConfig {
4399 no_session_stuck_timeout: 600,
4400 stuck_timeout: 1800,
4401 ..EngineConfig::default()
4402 };
4403 let updated_at = (chrono::Utc::now()
4404 - chrono::Duration::seconds(config.no_session_stuck_timeout as i64 + 5))
4405 .to_rfc3339();
4406
4407 let timing = stuck_task_timing_from_map(
4408 &tmux,
4409 repo,
4410 task_id,
4411 &updated_at,
4412 &config,
4413 "parse failure",
4414 &session_map,
4415 )
4416 .expect("dead session should be treated as missing");
4417
4418 assert!(!timing.has_session);
4419 assert_eq!(timing.threshold, config.no_session_stuck_timeout);
4420 }
4421
4422 #[tokio::test]
4423 #[serial_test::serial(suspend_state)]
4424 async fn stuck_task_timing_discounts_host_suspend_gap() {
4425 super::super::suspend::clear_for_test();
4426
4427 let tmux = Arc::new(TmuxManager::new());
4428 let repo = "owner/repo";
4429 let task_id = "internal:99001";
4430 let session_map = std::collections::HashMap::new();
4431
4432 let config = EngineConfig {
4433 no_session_stuck_timeout: 1800,
4434 stuck_timeout: 1800,
4435 ..EngineConfig::default()
4436 };
4437
4438 let updated_at = (chrono::Utc::now() - chrono::Duration::minutes(33)).to_rfc3339();
4442 let suspend_gap_detected_at = chrono::Utc::now() - chrono::Duration::minutes(1);
4443 super::super::suspend::inject_gap_for_test(
4444 suspend_gap_detected_at,
4445 chrono::Duration::minutes(30),
4446 );
4447
4448 let timing = stuck_task_timing_from_map(
4449 &tmux,
4450 repo,
4451 task_id,
4452 &updated_at,
4453 &config,
4454 "parse failure",
4455 &session_map,
4456 );
4457
4458 assert!(
4459 timing.is_none(),
4460 "task age should be discounted below the stuck threshold once suspend time is subtracted, got {timing:?}"
4461 );
4462
4463 super::super::suspend::clear_for_test();
4464 }
4465
4466 #[tokio::test]
4467 async fn recover_stuck_tasks_reclaims_routed_task_blocked_by_stale_session() {
4468 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4469 let mock = MockBackend::new();
4470 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4471 let task_manager = Arc::new(TaskManager::with_store(
4472 backend.clone(),
4473 store.clone(),
4474 "owner/repo".to_string(),
4475 ));
4476 let tmux = Arc::new(TmuxManager::new());
4477 let config = EngineConfig {
4478 no_session_stuck_timeout: 600,
4479 stuck_timeout: 1,
4480 ..EngineConfig::default()
4481 };
4482
4483 let id = store
4484 .create_internal("owner/repo", "Blocked routed task", "", "cron", "1", None)
4485 .await
4486 .unwrap();
4487 store
4488 .update_status(id, crate::store::TaskStatus::Routed)
4489 .await
4490 .unwrap();
4491 set_task_updated_at_past(&store, id).await;
4492
4493 let task_id = format!("internal:{id}");
4494 let session_name = tmux.session_name("owner/repo", &task_id);
4495 let create_result = tokio::process::Command::new("tmux")
4496 .args([
4497 "new-session",
4498 "-d",
4499 "-s",
4500 &session_name,
4501 "-c",
4502 "/tmp",
4503 "sleep 60",
4504 ])
4505 .output()
4506 .await;
4507
4508 match create_result {
4509 Ok(o) if o.status.success() => {}
4510 _ => {
4511 eprintln!("Skipping test: tmux not available or failed to create test session");
4512 return;
4513 }
4514 }
4515
4516 let session_map = tmux.batch_session_active().await;
4517 assert_eq!(session_map.get(&session_name).copied(), Some(true));
4518
4519 tick_recover_stuck_tasks(
4520 &backend,
4521 &tmux,
4522 "owner/repo",
4523 &task_manager,
4524 &config,
4525 &store,
4526 &session_map,
4527 &cooled_review_router_for_test(),
4528 &Arc::new(DashMap::new()),
4529 &Arc::new(DashSet::new()),
4530 )
4531 .await
4532 .unwrap();
4533
4534 let task = store.get(id).await.unwrap();
4535 assert_eq!(
4536 task.status,
4537 crate::store::TaskStatus::New,
4538 "routed task blocked by a stale session should be reset for re-routing"
4539 );
4540 assert!(
4541 !tmux.session_exists(&session_name).await,
4542 "recovery should kill the stale tmux session so dispatch can proceed"
4543 );
4544 }
4545
4546 #[tokio::test]
4560 async fn dispatch_does_not_deadlock_under_write_lock() {
4561 use crate::engine::router::Router;
4562 use tokio::sync::RwLock;
4563
4564 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4565 let mock = MockBackend::new();
4566 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4567 let task_manager = Arc::new(TaskManager::with_store(
4568 backend.clone(),
4569 store.clone(),
4570 "owner/repo".to_string(),
4571 ));
4572 let tmux = Arc::new(TmuxManager::new());
4573 let semaphore = Arc::new(Semaphore::new(4));
4574 let (weight_tx, _weight_rx) = mpsc::channel(16);
4575 let dispatching = Arc::new(DashMap::new());
4576 let transport = Arc::new(Transport::new());
4577 let capture = Arc::new(CaptureService::new(transport));
4578
4579 let router_config = crate::engine::router::RouterConfig::default();
4580 let router = Router::new(router_config);
4581 let router_arc = Arc::new(RwLock::new(router));
4582
4583 let write_guard = router_arc.write().await;
4587 let dispatch_mode = dispatch_mode_from_router(&write_guard);
4588
4589 let result = tokio::time::timeout(
4590 std::time::Duration::from_secs(2),
4591 tick_dispatch_tasks(
4592 &backend,
4593 &tmux,
4594 "owner/repo",
4595 &Arc::new(crate::engine::runner::TaskRunner::new(
4596 "owner/repo".to_string(),
4597 )),
4598 &capture,
4599 &semaphore,
4600 &task_manager,
4601 &weight_tx,
4602 dispatch_mode,
4603 &dispatching,
4604 &store,
4605 &std::collections::HashMap::new(),
4606 ),
4607 )
4608 .await;
4609
4610 assert!(
4611 result.is_ok(),
4612 "tick_dispatch_tasks deadlocked! It tried to acquire a read lock \
4613 on router_arc while a write lock was already held (issue #1361)"
4614 );
4615 }
4616
4617 #[tokio::test]
4625 async fn dispatch_skips_task_whose_routed_model_entered_cooldown() {
4626 use crate::store::{StoreRoute, TaskStatus as DbStatus, UpsertExternal};
4627
4628 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4629 let mock = MockBackend::new();
4630 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4631 let task_manager = Arc::new(TaskManager::with_store(
4632 backend.clone(),
4633 store.clone(),
4634 "owner/repo".to_string(),
4635 ));
4636 let tmux = Arc::new(TmuxManager::new());
4637 let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
4638 let semaphore = Arc::new(Semaphore::new(4));
4639 let (weight_tx, _weight_rx) = mpsc::channel(16);
4640 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4641 let transport = Arc::new(Transport::new());
4642 let capture = Arc::new(CaptureService::new(transport));
4643
4644 let id = store
4645 .upsert_external(&UpsertExternal {
4646 repo: "owner/repo",
4647 ext_id: "999",
4648 title: "Stale routed task",
4649 body: "",
4650 author: "user",
4651 url: "https://github.com/owner/repo/issues/999",
4652 labels: &[],
4653 origin: "github",
4654 })
4655 .await
4656 .unwrap();
4657 store
4658 .store_route(&StoreRoute {
4659 id,
4660 agent: "minimax",
4661 model: Some("opus"),
4662 complexity: "medium",
4663 estimate: 0,
4664 reason: "test",
4665 profile: "{}",
4666 skills: "[]",
4667 })
4668 .await
4669 .unwrap();
4670 store.update_status(id, DbStatus::Routed).await.unwrap();
4671
4672 crate::engine::cooldown::set_model_cooldown(
4675 "minimax",
4676 "opus",
4677 300,
4678 "rate_limit_usage_window",
4679 )
4680 .await;
4681
4682 let dispatch_mode = DispatchMode {
4683 is_degraded: false,
4684 healthy_agents: 1,
4685 threshold: 1,
4686 };
4687
4688 tick_dispatch_tasks(
4689 &backend,
4690 &tmux,
4691 "owner/repo",
4692 &runner,
4693 &capture,
4694 &semaphore,
4695 &task_manager,
4696 &weight_tx,
4697 dispatch_mode,
4698 &dispatching,
4699 &store,
4700 &std::collections::HashMap::new(),
4701 )
4702 .await
4703 .unwrap();
4704
4705 let task = store.get(id).await.unwrap();
4706 assert_eq!(
4707 task.status,
4708 DbStatus::Routed,
4709 "task whose routed model is now cooled down must stay Routed, not be dispatched"
4710 );
4711 assert!(
4712 dispatching.is_empty(),
4713 "dispatch guard must be released when skipping a stale-cooldown task"
4714 );
4715 }
4716
4717 #[tokio::test]
4718 async fn dispatch_skips_a_task_with_an_open_dependency() {
4719 use crate::store::{
4720 DepSource, NewDependency, StoreRoute, TaskStatus as DbStatus, UpsertExternal,
4721 };
4722
4723 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4724 let mock = MockBackend::new();
4725 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4726 let task_manager = Arc::new(TaskManager::with_store(
4727 backend.clone(),
4728 store.clone(),
4729 "owner/repo".to_string(),
4730 ));
4731 let tmux = Arc::new(TmuxManager::new());
4732 let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
4733 let semaphore = Arc::new(Semaphore::new(4));
4734 let (weight_tx, _weight_rx) = mpsc::channel(16);
4735 let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
4736 let transport = Arc::new(Transport::new());
4737 let capture = Arc::new(CaptureService::new(transport));
4738
4739 let id = store
4740 .upsert_external(&UpsertExternal {
4741 repo: "owner/repo",
4742 ext_id: "42",
4743 title: "Waiting routed task",
4744 body: "",
4745 author: "user",
4746 url: "https://github.com/owner/repo/issues/42",
4747 labels: &[],
4748 origin: "github",
4749 })
4750 .await
4751 .unwrap();
4752 store
4753 .replace_dependencies(
4754 id,
4755 &[NewDependency {
4756 repo: "owner/repo".into(),
4757 number: 7,
4758 source: DepSource::Github,
4759 }],
4760 )
4761 .await
4762 .unwrap();
4763 store
4764 .store_route(&StoreRoute {
4765 id,
4766 agent: "claude",
4767 model: Some("haiku"),
4768 complexity: "simple",
4769 estimate: 0,
4770 reason: "test",
4771 profile: "{}",
4772 skills: "[]",
4773 })
4774 .await
4775 .unwrap();
4776 store.update_status(id, DbStatus::Routed).await.unwrap();
4777
4778 let dispatch_mode = DispatchMode {
4779 is_degraded: false,
4780 healthy_agents: 1,
4781 threshold: 1,
4782 };
4783 tick_dispatch_tasks(
4784 &backend,
4785 &tmux,
4786 "owner/repo",
4787 &runner,
4788 &capture,
4789 &semaphore,
4790 &task_manager,
4791 &weight_tx,
4792 dispatch_mode,
4793 &dispatching,
4794 &store,
4795 &std::collections::HashMap::new(),
4796 )
4797 .await
4798 .unwrap();
4799
4800 let task = store.get(id).await.unwrap();
4801 assert_eq!(task.status, DbStatus::Waiting);
4802 assert!(dispatching.is_empty());
4803 }
4804
4805 #[tokio::test]
4818 async fn completed_session_does_not_cause_stuck_reclaim_race() {
4819 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4820 let mock = MockBackend::new();
4821 let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
4822 let task_manager = Arc::new(TaskManager::with_store(
4823 backend.clone(),
4824 store.clone(),
4825 "owner/repo".to_string(),
4826 ));
4827 let tmux = Arc::new(TmuxManager::new());
4828 let transport = Arc::new(Transport::new());
4829 let capture = Arc::new(CaptureService::new(transport));
4830
4831 let id = store
4833 .upsert_external(&crate::store::UpsertExternal {
4834 repo: "owner/repo",
4835 ext_id: "200",
4836 title: "Race Task",
4837 body: "",
4838 author: "",
4839 url: "",
4840 labels: &[],
4841 origin: "github",
4842 })
4843 .await
4844 .unwrap();
4845 store
4846 .update_status(id, crate::store::TaskStatus::InProgress)
4847 .await
4848 .unwrap();
4849
4850 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4852 .bind(id)
4853 .execute(store.pool())
4854 .await
4855 .unwrap();
4856
4857 let task_id = "200";
4859 let session_name = tmux.session_name("owner/repo", task_id);
4860 let create_result = tokio::process::Command::new("tmux")
4861 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
4862 .output()
4863 .await;
4864 match create_result {
4865 Ok(o) if o.status.success() => {}
4866 _ => {
4867 eprintln!("Skipping test: tmux not available or failed to create test session");
4868 return;
4869 }
4870 }
4871 let _guard = SessionGuard(session_name.clone());
4872
4873 let _ = tokio::process::Command::new("tmux")
4875 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
4876 .output()
4877 .await;
4878 let _ = tokio::process::Command::new("tmux")
4879 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
4880 .output()
4881 .await;
4882
4883 for _ in 0..20 {
4885 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4886 if !tmux.session_is_running(&session_name).await {
4887 break;
4888 }
4889 }
4890
4891 let task_key = task_id.to_string();
4893 capture
4894 .register_session("owner/repo", &task_key, &session_name)
4895 .await;
4896
4897 let config = EngineConfig {
4899 no_session_stuck_timeout: 600,
4900 stuck_timeout: 1800,
4901 ..EngineConfig::default()
4902 };
4903
4904 tick_check_session_completions(&tmux, "owner/repo", &capture, &store)
4905 .await
4906 .unwrap();
4907
4908 {
4914 let task_state = store.get(id).await.unwrap();
4915 if task_state.updated_at.starts_with("2020") {
4916 eprintln!(
4917 "Skipping test: tick_check_session_completions did not update stored \
4918 updated_at — session was not in snapshot (CI tmux environment issue)"
4919 );
4920 return;
4921 }
4922 }
4923
4924 tick_recover_stuck_tasks(
4926 &backend,
4927 &tmux,
4928 "owner/repo",
4929 &task_manager,
4930 &config,
4931 &store,
4932 &std::collections::HashMap::new(),
4933 &cooled_review_router_for_test(),
4934 &Arc::new(DashMap::new()),
4935 &Arc::new(DashSet::new()),
4936 )
4937 .await
4938 .unwrap();
4939
4940 let task = store.get(id).await.unwrap();
4942 assert_eq!(
4943 task.status,
4944 crate::store::TaskStatus::InProgress,
4945 "task should not be reclaimed to New"
4946 );
4947 }
4948
4949 #[tokio::test]
4959 async fn session_completion_matches_repo_configured_with_git_suffix() {
4960 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4961 let tmux = Arc::new(TmuxManager::new());
4962 let transport = Arc::new(Transport::new());
4963 let capture = Arc::new(CaptureService::new(transport));
4964
4965 let repo = "owner/repo.git";
4966 let id = store
4967 .upsert_external(&crate::store::UpsertExternal {
4968 repo,
4969 ext_id: "201",
4970 title: "Git-suffix repo task",
4971 body: "",
4972 author: "",
4973 url: "",
4974 labels: &[],
4975 origin: "github",
4976 })
4977 .await
4978 .unwrap();
4979 store
4980 .update_status(id, crate::store::TaskStatus::InProgress)
4981 .await
4982 .unwrap();
4983 sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
4984 .bind(id)
4985 .execute(store.pool())
4986 .await
4987 .unwrap();
4988
4989 let task_id = "201";
4992 let session_name = tmux.session_name(repo, task_id);
4993 let create_result = tokio::process::Command::new("tmux")
4994 .args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
4995 .output()
4996 .await;
4997 match create_result {
4998 Ok(o) if o.status.success() => {}
4999 _ => {
5000 eprintln!("Skipping test: tmux not available or failed to create test session");
5001 return;
5002 }
5003 }
5004 let _guard = SessionGuard(session_name.clone());
5005
5006 let _ = tokio::process::Command::new("tmux")
5007 .args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
5008 .output()
5009 .await;
5010 let _ = tokio::process::Command::new("tmux")
5011 .args(["send-keys", "-t", &session_name, "exit", "Enter"])
5012 .output()
5013 .await;
5014
5015 for _ in 0..20 {
5016 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
5017 if !tmux.session_is_running(&session_name).await {
5018 break;
5019 }
5020 }
5021
5022 capture.register_session(repo, task_id, &session_name).await;
5023
5024 tick_check_session_completions(&tmux, repo, &capture, &store)
5025 .await
5026 .unwrap();
5027
5028 let task_state = store.get(id).await.unwrap();
5029 if task_state.updated_at.starts_with("2020") {
5030 eprintln!(
5031 "Skipping test: tick_check_session_completions did not update stored \
5032 updated_at — session was not in snapshot (CI tmux environment issue)"
5033 );
5034 return;
5035 }
5036 assert!(
5037 !task_state.updated_at.starts_with("2020"),
5038 "tick_check_session_completions must match its own repo's live sessions \
5039 even when the repo is configured with a \".git\" suffix"
5040 );
5041 }
5042
5043 #[tokio::test]
5049 async fn job_scheduler_returns_ok_on_duplicate_job_config() {
5050 let tmp = tempfile::tempdir().unwrap();
5051 let cfg = tmp.path().join(".orch.yml");
5052 std::fs::write(
5053 &cfg,
5054 "jobs:\n - id: trading-scan\n schedule: \"0 9 * * *\"\n task:\n title: Scan\n body: do it\n",
5055 )
5056 .unwrap();
5057 let jobs_dir = tmp.path().join("prompts").join("jobs");
5058 std::fs::create_dir_all(&jobs_dir).unwrap();
5059 std::fs::write(
5060 jobs_dir.join("trading-scan.md"),
5061 "---\nid: trading-scan\nschedule: '0 9 * * *'\ntitle: Trading Scan\n---\n\nDo trading scan.\n",
5062 )
5063 .unwrap();
5064
5065 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5066 let result = tick_job_scheduler(&cfg, &backend, None, "test/repo", None).await;
5067 assert!(
5068 result.is_ok(),
5069 "duplicate job id must not propagate as Err: {result:?}"
5070 );
5071 }
5072
5073 #[tokio::test]
5077 async fn job_scheduler_processes_valid_project_when_other_has_duplicate() {
5078 let bad_tmp = tempfile::tempdir().unwrap();
5080 let bad_cfg = bad_tmp.path().join(".orch.yml");
5081 std::fs::write(
5082 &bad_cfg,
5083 "jobs:\n - id: dup\n schedule: \"0 9 * * *\"\n task:\n title: Dup\n body: x\n",
5084 )
5085 .unwrap();
5086 let bad_jobs_dir = bad_tmp.path().join("prompts").join("jobs");
5087 std::fs::create_dir_all(&bad_jobs_dir).unwrap();
5088 std::fs::write(
5089 bad_jobs_dir.join("dup.md"),
5090 "---\nid: dup\nschedule: '0 9 * * *'\ntitle: Dup\n---\n\nbody\n",
5091 )
5092 .unwrap();
5093
5094 let good_tmp = tempfile::tempdir().unwrap();
5096 let good_cfg = good_tmp.path().join(".orch.yml");
5097 std::fs::write(
5098 &good_cfg,
5099 "jobs:\n - id: valid-job\n schedule: \"0 9 * * *\"\n task:\n title: Valid\n body: ok\n",
5100 )
5101 .unwrap();
5102
5103 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
5104
5105 let bad_result = tick_job_scheduler(&bad_cfg, &backend, None, "test/bad-repo", None).await;
5106 assert!(
5107 bad_result.is_ok(),
5108 "bad project must not abort scheduler: {bad_result:?}"
5109 );
5110
5111 let good_result =
5112 tick_job_scheduler(&good_cfg, &backend, None, "test/good-repo", None).await;
5113 assert!(
5114 good_result.is_ok(),
5115 "valid project must still run after bad project: {good_result:?}"
5116 );
5117 }
5118
5119 #[serial_test::serial(cooldown_state)]
5127 #[tokio::test]
5128 async fn global_sweep_routes_inactive_repo_tasks() {
5129 use crate::engine::router::{Router, RouterConfig};
5130 use crate::store::{TaskStatus, UpsertExternal};
5131
5132 crate::engine::cooldown::reset_global_state().await;
5133
5134 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5135
5136 let active_id = store
5138 .upsert_external(&UpsertExternal {
5139 repo: "active/repo",
5140 ext_id: "1",
5141 title: "Active task",
5142 body: "",
5143 author: "user",
5144 url: "https://github.com/active/repo/issues/1",
5145 labels: &[],
5146 origin: "github",
5147 })
5148 .await
5149 .unwrap();
5150
5151 let inactive_id = store
5153 .upsert_external(&UpsertExternal {
5154 repo: "inactive/repo",
5155 ext_id: "42",
5156 title: "Orphaned task",
5157 body: "",
5158 author: "user",
5159 url: "https://github.com/inactive/repo/issues/42",
5160 labels: &[],
5161 origin: "github",
5162 })
5163 .await
5164 .unwrap();
5165
5166 assert_eq!(store.get(active_id).await.unwrap().status, TaskStatus::New);
5167 assert_eq!(
5168 store.get(inactive_id).await.unwrap().status,
5169 TaskStatus::New
5170 );
5171
5172 let mut model_map: std::collections::HashMap<
5173 String,
5174 std::collections::HashMap<String, Vec<String>>,
5175 > = std::collections::HashMap::new();
5176 let mut tier = std::collections::HashMap::new();
5177 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5178 model_map.insert("medium".to_string(), tier);
5179 let config = RouterConfig {
5180 mode: "round_robin".to_string(),
5181 agents: vec!["claude".to_string()],
5182 model_map,
5183 ..Default::default()
5184 };
5185 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5186
5187 let active_repos: std::collections::HashSet<String> =
5188 ["active/repo".to_string()].into_iter().collect();
5189 let mut routing_quota = RoutingTickQuota::new(1);
5190 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5191 .await
5192 .unwrap();
5193
5194 assert_eq!(
5196 store.get(active_id).await.unwrap().status,
5197 TaskStatus::New,
5198 "global sweep must not touch tasks for the active repo"
5199 );
5200
5201 let after = store.get(inactive_id).await.unwrap();
5203 assert_eq!(
5204 after.status,
5205 TaskStatus::Routed,
5206 "global sweep must route tasks from inactive repos"
5207 );
5208 assert!(
5209 matches!(&after.agent, Some(a) if !a.is_empty()),
5210 "agent must be set after routing"
5211 );
5212 }
5213
5214 #[serial_test::serial(cooldown_state)]
5222 #[tokio::test]
5223 async fn global_sweep_does_not_touch_other_active_repo_tasks() {
5224 use crate::engine::router::{Router, RouterConfig};
5225 use crate::store::{TaskStatus, UpsertExternal};
5226
5227 crate::engine::cooldown::reset_global_state().await;
5228
5229 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5230
5231 let other_active_id = store
5234 .upsert_external(&UpsertExternal {
5235 repo: "other-active/repo",
5236 ext_id: "7",
5237 title: "Other active project's task",
5238 body: "",
5239 author: "user",
5240 url: "https://github.com/other-active/repo/issues/7",
5241 labels: &[],
5242 origin: "github",
5243 })
5244 .await
5245 .unwrap();
5246
5247 let inactive_id = store
5249 .upsert_external(&UpsertExternal {
5250 repo: "inactive/repo",
5251 ext_id: "42",
5252 title: "Orphaned task",
5253 body: "",
5254 author: "user",
5255 url: "https://github.com/inactive/repo/issues/42",
5256 labels: &[],
5257 origin: "github",
5258 })
5259 .await
5260 .unwrap();
5261
5262 let mut model_map: std::collections::HashMap<
5263 String,
5264 std::collections::HashMap<String, Vec<String>>,
5265 > = std::collections::HashMap::new();
5266 let mut tier = std::collections::HashMap::new();
5267 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5268 model_map.insert("medium".to_string(), tier);
5269 let config = RouterConfig {
5270 mode: "round_robin".to_string(),
5271 agents: vec!["claude".to_string()],
5272 model_map,
5273 ..Default::default()
5274 };
5275 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5276
5277 let active_repos: std::collections::HashSet<String> =
5281 ["active/repo".to_string(), "other-active/repo".to_string()]
5282 .into_iter()
5283 .collect();
5284 let mut routing_quota = RoutingTickQuota::new(1);
5285 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5286 .await
5287 .unwrap();
5288
5289 assert_eq!(
5290 store.get(other_active_id).await.unwrap().status,
5291 TaskStatus::New,
5292 "global sweep run from a different active repo's tick must not route \
5293 another active repo's new tasks — that repo's own tick will route them"
5294 );
5295 assert_eq!(
5296 store.get(inactive_id).await.unwrap().status,
5297 TaskStatus::Routed,
5298 "global sweep must still route tasks from genuinely orphaned repos"
5299 );
5300 }
5301
5302 #[serial_test::serial(cooldown_state)]
5304 #[tokio::test]
5305 async fn global_sweep_skips_no_agent_tasks() {
5306 use crate::engine::router::{Router, RouterConfig};
5307 use crate::store::{TaskStatus, UpsertExternal};
5308
5309 crate::engine::cooldown::reset_global_state().await;
5310
5311 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5312
5313 let id = store
5314 .upsert_external(&UpsertExternal {
5315 repo: "inactive/repo",
5316 ext_id: "99",
5317 title: "No-agent task",
5318 body: "",
5319 author: "user",
5320 url: "https://github.com/inactive/repo/issues/99",
5321 labels: &["no-agent".to_string()],
5322 origin: "github",
5323 })
5324 .await
5325 .unwrap();
5326
5327 let mut model_map: std::collections::HashMap<
5328 String,
5329 std::collections::HashMap<String, Vec<String>>,
5330 > = std::collections::HashMap::new();
5331 let mut tier = std::collections::HashMap::new();
5332 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5333 model_map.insert("medium".to_string(), tier);
5334 let config = RouterConfig {
5335 mode: "round_robin".to_string(),
5336 agents: vec!["claude".to_string()],
5337 model_map,
5338 ..Default::default()
5339 };
5340 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5341
5342 let active_repos: std::collections::HashSet<String> =
5343 ["active/repo".to_string()].into_iter().collect();
5344 let mut routing_quota = RoutingTickQuota::new(1);
5345 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5346 .await
5347 .unwrap();
5348
5349 assert_eq!(
5350 store.get(id).await.unwrap().status,
5351 TaskStatus::New,
5352 "no-agent tasks must not be routed"
5353 );
5354 }
5355
5356 #[serial_test::serial(cooldown_state)]
5361 #[tokio::test]
5362 async fn global_sweep_honors_shared_routing_quota() {
5363 use crate::engine::router::{Router, RouterConfig};
5364 use crate::store::{TaskStatus, UpsertExternal};
5365
5366 crate::engine::cooldown::reset_global_state().await;
5367
5368 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5369 for ext_id in ["1", "2"] {
5370 store
5371 .upsert_external(&UpsertExternal {
5372 repo: "inactive/repo",
5373 ext_id,
5374 title: "Orphaned task",
5375 body: "",
5376 author: "user",
5377 url: &format!("https://github.com/inactive/repo/issues/{ext_id}"),
5378 labels: &[],
5379 origin: "github",
5380 })
5381 .await
5382 .unwrap();
5383 }
5384
5385 let mut model_map: std::collections::HashMap<
5386 String,
5387 std::collections::HashMap<String, Vec<String>>,
5388 > = std::collections::HashMap::new();
5389 let mut tier = std::collections::HashMap::new();
5390 tier.insert("claude".to_string(), vec!["haiku".to_string()]);
5391 model_map.insert("medium".to_string(), tier);
5392 let config = RouterConfig {
5393 mode: "round_robin".to_string(),
5394 agents: vec!["claude".to_string()],
5395 model_map,
5396 ..Default::default()
5397 };
5398 let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
5399
5400 let active_repos: std::collections::HashSet<String> =
5401 ["active/repo".to_string()].into_iter().collect();
5402 let mut routing_quota = RoutingTickQuota::new(1);
5403 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5404 .await
5405 .unwrap();
5406
5407 let all_tasks = store.list_all_global().await.unwrap();
5408 let routed = all_tasks
5409 .iter()
5410 .filter(|task| task.status == TaskStatus::Routed)
5411 .count();
5412 let still_new = all_tasks
5413 .iter()
5414 .filter(|task| task.status == TaskStatus::New)
5415 .count();
5416
5417 assert_eq!(routed, 1, "only one task may consume the shared quota");
5418 assert_eq!(still_new, 1, "remaining tasks wait for the next tick");
5419
5420 route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
5421 .await
5422 .unwrap();
5423 let after_exhausted = store.list_all_global().await.unwrap();
5424 assert_eq!(
5425 after_exhausted
5426 .iter()
5427 .filter(|task| task.status == TaskStatus::Routed)
5428 .count(),
5429 1,
5430 "an exhausted shared quota must not reset within the same tick"
5431 );
5432 }
5433
5434 #[tokio::test]
5451 async fn group_orphaned_routed_tasks_selects_inactive_repo_tasks() {
5452 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
5453
5454 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5455
5456 async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
5457 let id = store
5458 .upsert_external(&UpsertExternal {
5459 repo,
5460 ext_id,
5461 title: "Routed task",
5462 body: "",
5463 author: "user",
5464 url: &format!("https://github.com/{repo}/issues/{ext_id}"),
5465 labels: &[],
5466 origin: "github",
5467 })
5468 .await
5469 .unwrap();
5470 store
5471 .store_route(&StoreRoute {
5472 id,
5473 agent: "claude",
5474 model: Some("sonnet"),
5475 complexity: "medium",
5476 estimate: 0,
5477 reason: "test",
5478 profile: "{}",
5479 skills: "[]",
5480 })
5481 .await
5482 .unwrap();
5483 store.update_status(id, TaskStatus::Routed).await.unwrap();
5484 id
5485 }
5486
5487 let active_id = make_routed(&store, "active/repo", "1").await;
5488 let inactive_id_a = make_routed(&store, "inactive/repo", "2").await;
5489 let other_inactive_id = make_routed(&store, "other-inactive/repo", "3").await;
5490 let inactive_id_b = make_routed(&store, "inactive/repo", "4").await;
5491
5492 let all_routed = store
5493 .list_all_by_status_global(TaskStatus::Routed)
5494 .await
5495 .unwrap();
5496 let active_repos: std::collections::HashSet<String> =
5497 ["active/repo".to_string()].into_iter().collect();
5498 let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
5499
5500 assert!(
5501 !by_repo.contains_key("active/repo"),
5502 "global sweep must not select tasks for the active repo"
5503 );
5504 let inactive_ids: std::collections::HashSet<i64> = by_repo
5505 .get("inactive/repo")
5506 .expect("inactive/repo must be selected")
5507 .iter()
5508 .map(|t| t.id)
5509 .collect();
5510 assert_eq!(
5511 inactive_ids,
5512 [inactive_id_a, inactive_id_b].into_iter().collect(),
5513 "global sweep must group all orphaned tasks for a repo together"
5514 );
5515 assert_eq!(
5516 by_repo
5517 .get("other-inactive/repo")
5518 .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
5519 Some(vec![other_inactive_id])
5520 );
5521
5522 assert_eq!(
5525 store.get(active_id).await.unwrap().status,
5526 TaskStatus::Routed
5527 );
5528 }
5529
5530 #[tokio::test]
5535 async fn group_orphaned_routed_tasks_excludes_all_active_repos_not_just_current() {
5536 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
5537
5538 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5539
5540 async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
5541 let id = store
5542 .upsert_external(&UpsertExternal {
5543 repo,
5544 ext_id,
5545 title: "Routed task",
5546 body: "",
5547 author: "user",
5548 url: &format!("https://github.com/{repo}/issues/{ext_id}"),
5549 labels: &[],
5550 origin: "github",
5551 })
5552 .await
5553 .unwrap();
5554 store
5555 .store_route(&StoreRoute {
5556 id,
5557 agent: "claude",
5558 model: Some("sonnet"),
5559 complexity: "medium",
5560 estimate: 0,
5561 reason: "test",
5562 profile: "{}",
5563 skills: "[]",
5564 })
5565 .await
5566 .unwrap();
5567 store.update_status(id, TaskStatus::Routed).await.unwrap();
5568 id
5569 }
5570
5571 let repo_a_id = make_routed(&store, "active/repo-a", "1").await;
5572 let repo_b_id = make_routed(&store, "active/repo-b", "2").await;
5573 let orphaned_id = make_routed(&store, "inactive/repo", "3").await;
5574
5575 let all_routed = store
5576 .list_all_by_status_global(TaskStatus::Routed)
5577 .await
5578 .unwrap();
5579 let active_repos: std::collections::HashSet<String> =
5582 ["active/repo-a".to_string(), "active/repo-b".to_string()]
5583 .into_iter()
5584 .collect();
5585 let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
5586
5587 assert!(
5588 !by_repo.contains_key("active/repo-a"),
5589 "the current repo's own tasks must not be swept"
5590 );
5591 assert!(
5592 !by_repo.contains_key("active/repo-b"),
5593 "another active repo's tasks must not be swept during repo A's tick"
5594 );
5595 assert_eq!(
5596 by_repo
5597 .get("inactive/repo")
5598 .map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
5599 Some(vec![orphaned_id]),
5600 "a genuinely inactive repo's tasks must still be swept"
5601 );
5602
5603 assert_eq!(
5605 store.get(repo_a_id).await.unwrap().status,
5606 TaskStatus::Routed
5607 );
5608 assert_eq!(
5609 store.get(repo_b_id).await.unwrap().status,
5610 TaskStatus::Routed
5611 );
5612 }
5613
5614 #[tokio::test]
5616 async fn global_dispatch_sweep_skips_no_agent_tasks() {
5617 use crate::store::{StoreRoute, TaskStatus, UpsertExternal};
5618
5619 let store = Arc::new(TaskStore::open_memory().await.unwrap());
5620
5621 let id = store
5622 .upsert_external(&UpsertExternal {
5623 repo: "inactive/repo",
5624 ext_id: "99",
5625 title: "No-agent routed task",
5626 body: "",
5627 author: "user",
5628 url: "https://github.com/inactive/repo/issues/99",
5629 labels: &["no-agent".to_string()],
5630 origin: "github",
5631 })
5632 .await
5633 .unwrap();
5634 store
5635 .store_route(&StoreRoute {
5636 id,
5637 agent: "claude",
5638 model: Some("sonnet"),
5639 complexity: "medium",
5640 estimate: 0,
5641 reason: "test",
5642 profile: "{}",
5643 skills: "[]",
5644 })
5645 .await
5646 .unwrap();
5647 store.update_status(id, TaskStatus::Routed).await.unwrap();
5648
5649 let tmux = Arc::new(TmuxManager::new());
5654 let transport = Arc::new(Transport::new());
5655 let capture = Arc::new(CaptureService::new(transport));
5656 let semaphore = Arc::new(Semaphore::new(4));
5657 let (weight_tx, _weight_rx) = mpsc::channel(16);
5658 let dispatching = Arc::new(DashMap::new());
5659 let dispatch_mode = DispatchMode {
5660 is_degraded: false,
5661 healthy_agents: 1,
5662 threshold: 1,
5663 };
5664
5665 let active_repos: std::collections::HashSet<String> =
5666 ["active/repo".to_string()].into_iter().collect();
5667 dispatch_routed_tasks_global(
5668 &tmux,
5669 &capture,
5670 &semaphore,
5671 &weight_tx,
5672 dispatch_mode,
5673 &dispatching,
5674 &store,
5675 &std::collections::HashMap::new(),
5676 &active_repos,
5677 )
5678 .await
5679 .unwrap();
5680
5681 assert_eq!(
5682 store.get(id).await.unwrap().status,
5683 TaskStatus::Routed,
5684 "no-agent tasks must not be dispatched"
5685 );
5686 }
5687}