use super::{EngineConfig, ProjectEngine};
use crate::backends::github::GitHubBackend;
use crate::backends::{ExternalBackend, ExternalId, ExternalTask, Status};
use crate::channels::capture::CaptureService;
use crate::channels::transport::Transport;
use crate::cmd_cache;
use crate::config;
use crate::engine;
use crate::engine::cooldown::{
SILENCE_AGENT_COOLDOWN_SECS, SILENCE_EXTENDED_COOLDOWN_SECS, github_circuit_remaining_secs,
is_agent_in_cooldown, is_github_circuit_open, is_model_in_cooldown,
record_agent_failure_with_message, record_silence_detection, set_agent_cooldown,
set_model_cooldown,
};
use crate::engine::dependencies;
use crate::engine::dispatch_guard::DispatchGuard;
use crate::engine::jobs;
use crate::engine::priority;
use crate::engine::priority::Priority;
use crate::engine::router::config as router_config;
use crate::engine::router::{
AllAgentsCooledError, RouteResultError, Router, RouterConfig, get_route_result,
};
use crate::engine::runner::response;
use crate::engine::runner::{TaskRunner, WeightSignal};
use crate::engine::sync;
use crate::engine::tasks;
use crate::engine::tasks::{TaskManager, is_internal_id};
use crate::github::http;
use crate::repo_context::REPO_CONTEXT;
use crate::store;
use crate::store::StoreRoute;
use crate::store::Task;
use crate::store::TaskStatus;
use crate::store::TaskStatus as DbStatus;
use crate::store::TaskStore;
use crate::store::WaitingOnDependency;
use crate::store::{store_set_by_id, store_touch_updated_at};
use crate::tmux::TmuxManager;
use dashmap::mapref::entry::Entry;
use dashmap::{DashMap, DashSet};
use futures::future;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Duration;
use std::time::{Instant, SystemTime};
use tokio::sync::RwLock;
use tokio::sync::{Semaphore, mpsc};
use tokio::time;
pub(crate) struct RoutingTickQuota {
remaining: usize,
}
impl RoutingTickQuota {
pub(crate) fn new(max: usize) -> Self {
Self { remaining: max }
}
fn is_exhausted(&self) -> bool {
self.remaining == 0
}
fn remaining(&self) -> usize {
self.remaining
}
fn consume_one(&mut self) -> bool {
if self.remaining == 0 {
return false;
}
self.remaining -= 1;
true
}
}
pub(crate) async fn startup_cleanup(tmux: &TmuxManager) {
let start_time = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
tracing::info!(
start_time,
"running startup cleanup for stale tmux sessions"
);
match tmux.kill_stale_sessions(start_time).await {
Ok(killed) => {
if killed > 0 {
tracing::info!(killed, "startup cleanup killed stale sessions");
} else {
tracing::debug!("no stale tmux sessions found on startup");
}
}
Err(e) => {
tracing::warn!(error = %e, "failed to run startup cleanup");
}
}
}
async fn recover_routed_blocked_dispatch(
backend: &Arc<dyn ExternalBackend>,
store: &Arc<TaskStore>,
task_manager: &Arc<TaskManager>,
repo: &str,
task: &ExternalTask,
timing: &StuckTaskTiming,
) {
tracing::warn!(
task_id = task.id.0,
age_mins = timing.age.num_minutes(),
threshold_mins = timing.threshold / 60,
"recovering routed task: dispatch blocked by stale tmux session → new"
);
let resolved_store_id = match store.resolve_task_id(repo, &task.id.0).await {
Ok(Some(id)) => Some(id),
Ok(None) => {
tracing::warn!(
task_id = %task.id.0,
repo,
"resolve_task_id returned None during routed-task recovery"
);
None
}
Err(e) => {
tracing::error!(
task_id = %task.id.0,
repo,
error = %e,
"resolve_task_id failed during routed-task recovery"
);
None
}
};
if let Some(store_id) = resolved_store_id {
if let Err(e) = store_set_by_id(
Some(store),
store_id,
&[
("agent", serde_json::Value::Null),
("model", serde_json::Value::Null),
("route_attempts", serde_json::json!(0)),
],
)
.await
{
tracing::warn!(
task_id = task.id.0,
?e,
"failed to clear routing fields for routed stuck task — will retry next tick"
);
return;
}
finalize_open_runs_for_recovery(
store,
store_id,
"stuck-task recovery: stale session blocking dispatch killed",
)
.await;
}
if let Err(e) = task_manager.update_task_status(&task.id, Status::New).await {
tracing::warn!(
task_id = task.id.0,
?e,
"failed to reset routed stuck task status"
);
return;
}
store::store_log_activity(
&Some(Arc::clone(store)),
repo,
&task.id.0,
"rerouted",
Some("routed"),
Some("new"),
None::<&str>,
None::<&str>,
Some(&serde_json::json!({
"failure_reason": "routed_blocked_by_existing_session",
"age_minutes": timing.age.num_minutes(),
"had_session": true,
})),
)
.await;
if !is_internal_id(&task.id.0) {
let backend_clone = Arc::clone(backend);
let task_id_clone = task.id.clone();
let body = format!(
"[{}] recovered: stuck routed task — existing tmux session blocked dispatch for {}m; cleared route for retry{}",
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
timing.age.num_minutes(),
engine::orch_footer()
);
tokio::spawn(async move {
if let Err(e) = backend_clone.post_comment(&task_id_clone, &body).await {
tracing::warn!(
task_id = task_id_clone.0,
?e,
"failed to post routed-task recovery comment"
);
}
});
}
}
pub(crate) async fn tick_check_session_completions(
tmux: &Arc<TmuxManager>,
repo: &str,
capture: &Arc<CaptureService>,
store: &Arc<TaskStore>,
) -> anyhow::Result<()> {
let _span = tracing::info_span!("engine.tick.phase1.sessions").entered();
let session_snapshot = tmux.snapshot().await;
let repo_name = repo
.rsplit('/')
.next()
.unwrap_or(repo)
.trim_end_matches(".git");
for (session, active) in session_snapshot {
if session.project != repo_name {
continue;
}
if !active {
tracing::info!(
session = %session.name,
task_id = %session.task_id,
"session completed, collecting results"
);
let store_task_id = {
let base = session
.task_id
.strip_suffix("-review")
.unwrap_or(&session.task_id);
if let Some(n) = base.strip_prefix("internal-") {
format!("internal:{n}")
} else {
base.to_string()
}
};
store_touch_updated_at(&Some(Arc::clone(store)), repo, &store_task_id).await;
capture.unregister_session(repo, &session.task_id).await;
if let Err(e) = tmux.kill_session(&session.name).await {
tracing::debug!(
session = %session.name,
?e,
"kill_session failed (session may already be gone)"
);
}
}
}
Ok(())
}
#[derive(Debug)]
struct StuckTaskTiming {
has_session: bool,
age: chrono::Duration,
threshold: u64,
}
fn stuck_task_timing_from_map(
tmux: &Arc<TmuxManager>,
repo: &str,
session_task_id: &str,
updated_at: &str,
config: &EngineConfig,
parse_error_message: &'static str,
session_map: &HashMap<String, bool>,
) -> Option<StuckTaskTiming> {
let has_session = session_alive(tmux, repo, session_task_id, session_map);
let threshold = if has_session {
config.stuck_timeout
} else {
config.no_session_stuck_timeout
};
let updated = match chrono::DateTime::parse_from_rfc3339(updated_at) {
Ok(dt) => dt.with_timezone(&chrono::Utc),
Err(e) => {
tracing::warn!(
task_id = session_task_id,
updated_at,
?e,
error_message = parse_error_message,
"cannot parse updated_at"
);
return None;
}
};
let raw_age = chrono::Utc::now() - updated;
let suspended = super::suspend::suspended_duration_since(updated);
let age = (raw_age - suspended).max(chrono::Duration::zero());
if age.num_seconds() <= i64::try_from(threshold).unwrap_or(i64::MAX) {
return None;
}
Some(StuckTaskTiming {
has_session,
age,
threshold,
})
}
fn session_alive(
tmux: &TmuxManager,
repo: &str,
session_task_id: &str,
session_map: &HashMap<String, bool>,
) -> bool {
session_map
.get(&tmux.session_name(repo, session_task_id))
.copied()
.unwrap_or(false)
}
fn run_tier(router: &RouterConfig, task: &Task) -> String {
match (task.agent.as_deref(), task.model.as_deref()) {
(Some(a), Some(m)) if !m.is_empty() => router.tier_of_model(a, m, &task.complexity),
_ => task.complexity.clone(),
}
}
fn tick_router(
cell: &OnceLock<RouterConfig>,
load: impl FnOnce() -> RouterConfig,
) -> &RouterConfig {
cell.get_or_init(load)
}
async fn in_progress_stuck_clock(
store: &TaskStore,
repo: &str,
task_id: &str,
updated_at: &str,
has_session: bool,
config: &EngineConfig,
router: &OnceLock<RouterConfig>,
) -> (String, EngineConfig) {
if !has_session {
return (updated_at.to_string(), config.clone());
}
let task = match store.resolve_task_id(repo, task_id).await {
Ok(Some(store_id)) => store.get(store_id).await.ok(),
_ => None,
};
let Some(task) = task else {
return (updated_at.to_string(), config.clone());
};
let entered_at = match store
.status_entered_at(task.id, TaskStatus::InProgress)
.await
{
Ok(Some(ts)) => ts,
Ok(None) => updated_at.to_string(),
Err(e) => {
tracing::warn!(
task_id,
error = %e,
"failed to read in_progress entry, falling back to updated_at"
);
updated_at.to_string()
}
};
let tier = run_tier(tick_router(router, RouterConfig::from_config), &task);
let task_config = EngineConfig {
stuck_timeout: config.live_session_stuck_limit(Some(&tier)),
..config.clone()
};
(entered_at, task_config)
}
async fn in_review_stuck_entry(store: &TaskStore, store_id: i64, fallback: &str) -> (String, bool) {
match store.in_review_entry(store_id).await {
Ok(Some(entry)) => entry,
Ok(None) => (fallback.to_string(), false),
Err(e) => {
tracing::warn!(
store_id,
error = %e,
"failed to read in_review entry, falling back to updated_at"
);
(fallback.to_string(), false)
}
}
}
async fn finalize_open_runs_for_recovery(store: &TaskStore, store_id: i64, reason: &str) {
if let Err(e) = store
.finalize_incomplete_runs(store_id, "aborted", reason)
.await
{
tracing::warn!(
store_id,
error = %e,
reason,
"failed to finalize incomplete runs during recovery"
);
}
}
pub(crate) async fn tick_detect_silent_agents(
tmux: &Arc<TmuxManager>,
repo: &str,
capture: &Arc<CaptureService>,
backend: &Arc<dyn ExternalBackend>,
task_manager: &Arc<TaskManager>,
config: &EngineConfig,
store: &Arc<TaskStore>,
) -> anyhow::Result<()> {
let _span = tracing::info_span!("engine.tick.phase1b.silence").entered();
let grace = Duration::from_secs(config.silence_grace_period);
let silent_sessions = capture.get_silent_sessions_for_repo(repo, grace).await;
for (task_id, session_name, silence_age_secs) in silent_sessions {
let use_backend = should_use_backend(&task_id);
let store_task = match store.resolve_task_id(repo, &task_id).await {
Ok(Some(store_id)) => match store.get(store_id).await {
Ok(task) => Some(task),
Err(e) => {
tracing::warn!(
task_id,
repo,
store_id,
operation = "store.get",
error = %e,
"silence detection: db error fetching task — agent/model cooldown will be skipped"
);
None
}
},
Ok(None) => {
tracing::debug!(
task_id,
repo,
operation = "resolve_task_id",
"silence detection: task not found in store (may have been cleaned up)"
);
None
}
Err(e) => {
tracing::warn!(
task_id,
repo,
operation = "resolve_task_id",
error = %e,
"silence detection: db error resolving task id — agent/model cooldown will be skipped"
);
None
}
};
let agent_name = store_task
.as_ref()
.and_then(|t| t.agent.clone())
.unwrap_or_default();
let model_name = store_task
.as_ref()
.and_then(|t| t.model.clone())
.unwrap_or_default();
tracing::warn!(
task_id,
agent = %agent_name,
model = %model_name,
silence_age_secs,
grace_secs = config.silence_grace_period,
cooldown_secs = config.silence_cooldown,
"agent silent since session start — killing session, cooling down model + agent, failing over"
);
if let Err(e) = tmux.kill_session(&session_name).await {
tracing::debug!(
task_id,
?e,
"kill_session failed for silent agent (may already be gone)"
);
}
capture.unregister_session(repo, &task_id).await;
if let Some(store_id) = store_task.as_ref().map(|t| t.id) {
finalize_open_runs_for_recovery(store, store_id, "silence detection: session killed")
.await;
}
let mut extended_note = String::new();
if !agent_name.is_empty() && !model_name.is_empty() {
set_model_cooldown(
&agent_name,
&model_name,
config.silence_cooldown,
"silence_detected",
)
.await;
if let Some(result) = record_silence_detection(&agent_name, &model_name).await
&& result.extended_cooldown_applied
{
extended_note = format!(
" ({} silences in 24h -> extended cooldown {}s)",
result.count, SILENCE_EXTENDED_COOLDOWN_SECS
);
}
}
if !agent_name.is_empty() {
set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
}
let task_eid = ExternalId(task_id.clone());
let available: Vec<String> = engine::configured_agents()
.into_iter()
.filter(|a| cmd_cache::command_exists(a))
.collect();
let chain = response::get_reroute_chain(&task_id, &Some(Arc::clone(store)), repo).await;
let chain = response::update_reroute_chain(
&task_id,
&agent_name,
&chain,
&Some(Arc::clone(store)),
repo,
)
.await;
let (next_status, next_agent) = if let Some(fallback) =
response::pick_fallback_agent(&agent_name, &chain, &available)
{
tracing::info!(
task_id,
from = %agent_name,
to = %fallback,
"silence detection: failover to different agent, setting routed"
);
(Status::Routed, Some(fallback))
} else {
tracing::warn!(
task_id,
"silence detection: no fallback agents available, resetting to new for re-routing"
);
(Status::New, None)
};
if use_backend && let Some(ref st) = store_task {
let stale_labels: Vec<String> = st
.labels
.iter()
.filter(|l| {
l.starts_with("agent:")
|| l.starts_with("complexity:")
|| l.starts_with("model:")
})
.cloned()
.collect();
if !stale_labels.is_empty() {
let backend_clone = Arc::clone(backend);
let task_eid_clone = task_eid.clone();
tokio::spawn(async move {
for label in &stale_labels {
if let Err(e) = backend_clone.remove_label(&task_eid_clone, label).await {
tracing::warn!(task_id = task_eid_clone.0, label, error = %e, "failed to remove label during silence detection re-route");
}
}
});
}
}
if let Some(store_id) = store_task.as_ref().map(|t| t.id) {
if let Some(ref fallback) = next_agent {
let _ = store_set_by_id(
Some(store),
store_id,
&[
("agent", serde_json::json!(fallback)),
("model", serde_json::json!("")),
(
"last_error",
serde_json::json!(format!(
"silence detected after {}s, failing over to {}",
config.silence_grace_period, fallback
)),
),
],
)
.await;
} else {
let _ = store_set_by_id(
Some(store),
store_id,
&[
("agent", serde_json::Value::Null),
("model", serde_json::Value::Null),
(
"last_error",
serde_json::json!(format!(
"silence detected after {}s, no fallback agents available",
config.silence_grace_period
)),
),
],
)
.await;
}
}
if let Err(e) = task_manager
.update_task_status(&task_eid, next_status)
.await
{
tracing::warn!(task_id, ?e, "failed to update silent task status");
continue;
}
let details = serde_json::json!({
"failure_reason": "agent_silence",
"silence_duration_secs": config.silence_grace_period,
"cooldown_applied": !agent_name.is_empty() && !model_name.is_empty(),
"fallback_available": next_agent.is_some(),
});
store::store_log_activity(
&Some(Arc::clone(store)),
repo,
&task_id,
"rerouted",
Some("in_progress"),
Some(next_status.as_str()),
next_agent.as_deref(),
None,
Some(&details),
)
.await;
let action = if let Some(ref fallback) = next_agent {
format!("failing over to {fallback}")
} else {
"marking needs_review (no fallback agents)".to_string()
};
let comment = format!(
"[{}] agent silent for {}s since session start — killed session, cooled down model `{}:{}` for {}s{}, agent `{}` for {}s, {}{}",
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
config.silence_grace_period,
agent_name,
model_name,
config.silence_cooldown,
extended_note,
agent_name,
SILENCE_AGENT_COOLDOWN_SECS,
action,
engine::orch_footer(),
);
if use_backend {
let backend_clone = Arc::clone(backend);
let task_eid_clone = task_eid.clone();
tokio::spawn(async move {
if let Err(e) = backend_clone.post_comment(&task_eid_clone, &comment).await {
tracing::warn!(
task_id = task_eid_clone.0,
?e,
"failed to post silence detection comment"
);
}
});
}
}
Ok(())
}
fn should_use_backend(task_id: &str) -> bool {
!is_internal_id(task_id)
}
#[expect(
clippy::too_many_arguments,
reason = "each argument is engine state the tick passes down"
)]
pub(crate) async fn tick_recover_stuck_tasks(
backend: &Arc<dyn ExternalBackend>,
tmux: &Arc<TmuxManager>,
repo: &str,
task_manager: &Arc<TaskManager>,
config: &EngineConfig,
store: &Arc<TaskStore>,
session_map: &HashMap<String, bool>,
dispatching: &Arc<DashMap<String, String>>,
) -> anyhow::Result<()> {
let _span = tracing::info_span!("engine.tick.phase2.stuck_tasks").entered();
let tick_router_cell = OnceLock::new();
let in_progress = match task_manager
.list_external_by_status(Status::InProgress)
.await
{
Ok(tasks) => tasks,
Err(e) => {
tracing::warn!(?e, "failed to list in_progress tasks for stuck recovery");
vec![]
}
};
for task in &in_progress {
let (since, task_config) = in_progress_stuck_clock(
store,
repo,
&task.id.0,
&task.updated_at,
session_alive(tmux, repo, &task.id.0, session_map),
config,
&tick_router_cell,
)
.await;
let Some(timing) = stuck_task_timing_from_map(
tmux,
repo,
&task.id.0,
&since,
&task_config,
"cannot parse updated_at, skipping stuck-task check",
session_map,
) else {
continue;
};
if !timing.has_session {
let dispatch_key = format!("{repo}/{}", task.id.0);
if dispatching.contains_key(&dispatch_key) {
tracing::info!(
task_id = task.id.0,
dispatch_key,
age_mins = timing.age.num_minutes(),
"skipping stuck-task reclaim: runner still actively processing this task"
);
continue;
}
}
if timing.has_session {
tracing::warn!(
task_id = task.id.0,
age_mins = timing.age.num_minutes(),
threshold_mins = timing.threshold / 60,
"recovering stuck task: timed out with active session → new"
);
let session_name = tmux.session_name(repo, &task.id.0);
if let Err(e) = tmux.kill_session(&session_name).await {
tracing::warn!(
task_id = task.id.0,
session = %session_name,
error = %e,
"failed to kill stuck tmux session, will retry next tick"
);
continue;
}
} else {
tracing::warn!(
task_id = task.id.0,
dispatch_key = format!("{repo}/{}", task.id.0),
age_mins = timing.age.num_minutes(),
threshold_mins = timing.threshold / 60,
"recovering stuck task: no session found — reclaiming early → new"
);
}
let mut cached_store_id: Option<i64> = None;
if timing.has_session {
let store_task = match store.resolve_task_id(repo, &task.id.0).await {
Ok(Some(store_id)) => {
cached_store_id = Some(store_id);
match store.get(store_id).await {
Ok(t) => Some(t),
Err(e) => {
tracing::warn!(task_id = task.id.0, error = %e, "failed to fetch task from store for stuck-task cooldown");
None
}
}
}
Ok(None) => None,
Err(e) => {
tracing::warn!(task_id = task.id.0, error = %e, "failed to resolve task id for stuck-task cooldown");
None
}
};
let agent_name = store_task
.as_ref()
.and_then(|t| t.agent.clone())
.unwrap_or_default();
let model_name = store_task
.as_ref()
.and_then(|t| t.model.clone())
.unwrap_or_default();
if !agent_name.is_empty() && !model_name.is_empty() {
set_model_cooldown(
&agent_name,
&model_name,
config.silence_cooldown,
"silence_detected",
)
.await;
record_agent_failure_with_message(
&agent_name,
&format!(
"stuck with active session after {}m",
timing.age.num_minutes()
),
)
.await;
}
if !agent_name.is_empty() {
set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
}
tracing::warn!(
task_id = task.id.0,
agent = %agent_name,
model = %model_name,
cooldown_secs = config.silence_cooldown,
"stuck-task cooldown applied — router will avoid this agent/model on next attempt"
);
}
{
let stale_labels: Vec<String> = task
.labels
.iter()
.filter(|l| l.starts_with("agent:") || l.starts_with("model:"))
.cloned()
.collect();
if !stale_labels.is_empty() {
let backend_clone = Arc::clone(backend);
let task_id_clone = task.id.clone();
tokio::spawn(async move {
for label in &stale_labels {
if let Err(e) = backend_clone.remove_label(&task_id_clone, label).await {
tracing::warn!(task_id = task_id_clone.0, label, error = %e, "failed to remove stale routing label during stuck-task recovery");
}
}
});
}
}
let resolved_store_id = match cached_store_id {
Some(id) => Some(id),
None => match store.resolve_task_id(repo, &task.id.0).await {
Ok(Some(id)) => Some(id),
Ok(None) => {
tracing::warn!(
task_id = %task.id.0,
repo,
"resolve_task_id returned None during stuck-task recovery"
);
None
}
Err(e) => {
tracing::error!(
task_id = %task.id.0,
repo,
error = %e,
"resolve_task_id failed during stuck-task recovery"
);
None
}
},
};
if let Some(store_id) = resolved_store_id {
if let Err(e) = store_set_by_id(
Some(store),
store_id,
&[
("agent", serde_json::Value::Null),
("model", serde_json::Value::Null),
("route_attempts", serde_json::json!(0)),
],
)
.await
{
tracing::warn!(
task_id = task.id.0,
?e,
"failed to clear routing fields for stuck task — will retry next tick"
);
continue;
}
finalize_open_runs_for_recovery(
store,
store_id,
if timing.has_session {
"stuck-task recovery: active session killed"
} else {
"stuck-task recovery: no session found"
},
)
.await;
}
if let Err(e) = task_manager.update_task_status(&task.id, Status::New).await {
tracing::warn!(task_id = task.id.0, ?e, "failed to reset stuck task status");
continue;
}
let details = serde_json::json!({
"failure_reason": if timing.has_session { "stuck_with_session" } else { "stuck_no_session" },
"age_minutes": timing.age.num_minutes(),
"had_session": timing.has_session,
});
store::store_log_activity(
&Some(Arc::clone(store)),
repo,
&task.id.0,
"rerouted",
Some("in_progress"),
Some("new"),
None::<&str>,
None::<&str>,
Some(&details),
)
.await;
let reason = if timing.has_session {
format!(
"timed out after {}m with active session (cleared agent for re-routing)",
timing.age.num_minutes()
)
} else {
format!(
"no session found — reclaiming early after {}m (cleared agent for re-routing)",
timing.age.num_minutes()
)
};
{
let backend_clone = Arc::clone(backend);
let task_id_clone = task.id.clone();
let body = format!(
"[{}] recovered: stuck in_progress — {}{}",
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
reason,
engine::orch_footer()
);
tokio::spawn(async move {
if let Err(e) = backend_clone.post_comment(&task_id_clone, &body).await {
tracing::warn!(
task_id = task_id_clone.0,
?e,
"failed to post stuck-task recovery comment"
);
}
});
}
}
let mut routed = match task_manager.list_external_by_status(Status::Routed).await {
Ok(tasks) => tasks,
Err(e) => {
tracing::warn!(?e, "failed to list routed tasks for stuck recovery");
vec![]
}
};
match task_manager
.list_internal_by_status(TaskStatus::Routed)
.await
{
Ok(tasks) => routed.extend(tasks),
Err(e) => tracing::warn!(
?e,
"failed to list internal routed tasks for stuck recovery"
),
}
for task in &routed {
let Some(timing) = stuck_task_timing_from_map(
tmux,
repo,
&task.id.0,
&task.updated_at,
config,
"cannot parse updated_at, skipping routed stuck-task check",
session_map,
) else {
continue;
};
if !timing.has_session {
continue;
}
let session_name = tmux.session_name(repo, &task.id.0);
if let Err(e) = tmux.kill_session(&session_name).await {
tracing::warn!(
task_id = task.id.0,
session = %session_name,
error = %e,
"failed to kill stale tmux session blocking routed task"
);
continue;
}
recover_routed_blocked_dispatch(backend, store, task_manager, repo, task, &timing).await;
}
let internal_in_progress = match task_manager
.list_internal_by_status(DbStatus::InProgress)
.await
{
Ok(tasks) => tasks,
Err(e) => {
tracing::warn!(
?e,
"failed to list internal in_progress tasks for stuck recovery"
);
vec![]
}
};
for task in &internal_in_progress {
let task_id = task.id.0.clone();
let (since, task_config) = in_progress_stuck_clock(
store,
repo,
&task_id,
&task.updated_at,
session_alive(tmux, repo, &task_id, session_map),
config,
&tick_router_cell,
)
.await;
let Some(timing) = stuck_task_timing_from_map(
tmux,
repo,
&task_id,
&since,
&task_config,
"cannot parse updated_at, skipping stuck internal-task check",
session_map,
) else {
continue;
};
if !timing.has_session {
let dispatch_key = format!("{repo}/{task_id}");
if dispatching.contains_key(&dispatch_key) {
tracing::info!(
task_id,
dispatch_key,
age_mins = timing.age.num_minutes(),
"skipping stuck-task reclaim: runner still actively processing this task"
);
continue;
}
}
if timing.has_session {
tracing::warn!(
task_id,
age_mins = timing.age.num_minutes(),
threshold_mins = timing.threshold / 60,
"recovering stuck task: timed out with active session → new"
);
let session_name = tmux.session_name(repo, &task_id);
if let Err(e) = tmux.kill_session(&session_name).await {
tracing::warn!(
task_id,
session = %session_name,
error = %e,
"failed to kill stuck tmux session, will retry next tick"
);
continue;
}
} else {
tracing::warn!(
task_id,
dispatch_key = format!("{repo}/{task_id}"),
age_mins = timing.age.num_minutes(),
threshold_mins = timing.threshold / 60,
"recovering stuck task: no session found — reclaiming early → new"
);
}
let mut cached_store_id: Option<i64> = None;
if timing.has_session {
let store_task = match store.resolve_task_id(repo, &task_id).await {
Ok(Some(store_id)) => {
cached_store_id = Some(store_id);
match store.get(store_id).await {
Ok(t) => Some(t),
Err(e) => {
tracing::warn!(task_id, error = %e, "failed to fetch task from store for stuck internal-task cooldown");
None
}
}
}
Ok(None) => None,
Err(e) => {
tracing::warn!(task_id, error = %e, "failed to resolve task id for stuck internal-task cooldown");
None
}
};
let agent_name = store_task
.as_ref()
.and_then(|t| t.agent.clone())
.unwrap_or_default();
let model_name = store_task
.as_ref()
.and_then(|t| t.model.clone())
.unwrap_or_default();
if !agent_name.is_empty() && !model_name.is_empty() {
set_model_cooldown(
&agent_name,
&model_name,
config.silence_cooldown,
"silence_detected",
)
.await;
record_agent_failure_with_message(
&agent_name,
&format!(
"internal task stuck with active session after {}m",
timing.age.num_minutes()
),
)
.await;
}
if !agent_name.is_empty() {
set_agent_cooldown(&agent_name, SILENCE_AGENT_COOLDOWN_SECS).await;
}
tracing::warn!(
task_id,
agent = %agent_name,
model = %model_name,
cooldown_secs = config.silence_cooldown,
"internal stuck-task cooldown applied — router will avoid this agent/model on next attempt"
);
}
let resolved_store_id = match cached_store_id {
Some(id) => Some(id),
None => match store.resolve_task_id(repo, &task_id).await {
Ok(Some(id)) => Some(id),
Ok(None) => {
tracing::warn!(
task_id = %task_id,
repo,
"resolve_task_id returned None during stuck internal-task recovery"
);
None
}
Err(e) => {
tracing::error!(
task_id = %task_id,
repo,
error = %e,
"resolve_task_id failed during stuck internal-task recovery"
);
None
}
},
};
if let Some(store_id) = resolved_store_id {
if let Err(e) = store_set_by_id(
Some(store),
store_id,
&[
("agent", serde_json::Value::Null),
("model", serde_json::Value::Null),
("route_attempts", serde_json::json!(0)),
],
)
.await
{
tracing::warn!(
task_id,
?e,
"failed to clear routing fields for stuck internal task — will retry next tick"
);
continue;
}
finalize_open_runs_for_recovery(
store,
store_id,
if timing.has_session {
"stuck-task recovery: internal active session killed"
} else {
"stuck-task recovery: internal no session found"
},
)
.await;
}
if let Err(e) = task_manager
.update_task_status(&ExternalId(task_id.clone()), Status::New)
.await
{
tracing::warn!(task_id, ?e, "failed to reset stuck internal task status");
}
}
let in_review = match store.list_by_status(repo, TaskStatus::InReview).await {
Ok(v) => v,
Err(e) => {
tracing::warn!(?e, "failed to list in_review tasks for stuck recovery");
vec![]
}
};
let in_review_config = engine::EngineConfig {
no_session_stuck_timeout: config.in_review_no_session_stuck_timeout,
..config.clone()
};
for task in &in_review {
let Some(task_id) = task.external_id.clone() else {
tracing::warn!(
store_id = task.id,
"in_review task has no external id, skipping stuck recovery"
);
continue;
};
if dispatching.contains_key(&format!("{repo}/{task_id}")) {
tracing::debug!(
task_id,
"task locked by dispatch flow, skipping stuck check"
);
continue;
}
let (entered_at, decided) = in_review_stuck_entry(store, task.id, &task.updated_at).await;
if decided {
tracing::debug!(
task_id,
"in_review task already has a review decision, waiting on merge"
);
continue;
}
let review_task_id = format!("{task_id}-review");
let Some(timing) = stuck_task_timing_from_map(
tmux,
repo,
&review_task_id,
&entered_at,
&in_review_config,
"cannot parse in_review timestamp, skipping stuck in_review check",
session_map,
) else {
continue;
};
tracing::warn!(
task_id,
age_mins = timing.age.num_minutes(),
threshold_mins = timing.threshold / 60,
has_session = timing.has_session,
"recovering stuck in_review task — resetting to needs_review"
);
match task_manager
.update_task_status_if(
&ExternalId(task_id.clone()),
Status::NeedsReview,
Status::InReview,
)
.await
{
Err(e) => {
tracing::warn!(task_id, ?e, "failed to reset stuck in_review task status");
continue;
}
Ok(false) => {
tracing::debug!(
task_id,
"stuck in_review reset skipped — task already transitioned"
);
continue;
}
Ok(true) => {}
}
if let Err(e) = store
.set_fields(task.id, &[("review_agent_failures", serde_json::json!(0))])
.await
{
tracing::warn!(task_id, ?e, "failed to reset review_agent_failures");
}
finalize_open_runs_for_recovery(
store,
task.id,
if timing.has_session {
"stuck-task recovery: in_review session killed"
} else {
"stuck-task recovery: in_review no session found"
},
)
.await;
let details = serde_json::json!({
"failure_reason": "stuck_in_review",
"age_minutes": timing.age.num_minutes(),
"had_session": timing.has_session,
});
store::store_log_activity(
&Some(Arc::clone(store)),
repo,
&task_id,
"rerouted",
Some("in_review"),
Some("needs_review"),
None::<&str>,
None::<&str>,
Some(&details),
)
.await;
if task.origin == "internal" {
continue;
}
if let Err(e) = backend
.post_comment(
&ExternalId(task_id.clone()),
&format!(
"[{}] recovered: stuck in_review — {} after {}m, resetting to needs_review{}",
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
if timing.has_session {
"review session hung"
} else {
"no session found"
},
timing.age.num_minutes(),
engine::orch_footer()
),
)
.await
{
tracing::warn!(
task_id,
?e,
"failed to post stuck in_review recovery comment"
);
}
}
Ok(())
}
pub(crate) async fn recover_untracked_repo_stuck_tasks(
tmux: &Arc<TmuxManager>,
config: &EngineConfig,
store: &Arc<TaskStore>,
session_map: &HashMap<String, bool>,
dispatching: &Arc<DashMap<String, String>>,
active_repos: &HashSet<String>,
) {
let all_external_in_progress = match store
.list_all_by_status_global(TaskStatus::InProgress)
.await
{
Ok(tasks) => tasks,
Err(e) => {
tracing::warn!(
?e,
"failed to list global in_progress tasks for cross-repo stuck recovery"
);
vec![]
}
};
for task in all_external_in_progress
.iter()
.filter(|t| t.origin != "internal" && !active_repos.contains(&t.repo))
{
let external_id = task
.external_id
.clone()
.unwrap_or_else(|| task.id.to_string());
let Some(timing) = stuck_task_timing_from_map(
tmux,
&task.repo,
&external_id,
&task.updated_at,
config,
"cannot parse updated_at, skipping cross-repo stuck-task check",
session_map,
) else {
continue;
};
if !timing.has_session {
let dispatch_key = format!("{}/{external_id}", task.repo);
if dispatching.contains_key(&dispatch_key) {
tracing::info!(
task_id = task.id,
repo = %task.repo,
dispatch_key,
age_mins = timing.age.num_minutes(),
"skipping cross-repo stuck-task reclaim: runner still actively processing this task"
);
continue;
}
}
if timing.has_session {
tracing::warn!(
task_id = task.id,
repo = %task.repo,
age_mins = timing.age.num_minutes(),
threshold_mins = timing.threshold / 60,
"recovering cross-repo stuck task: timed out with active session → new"
);
} else {
tracing::warn!(
task_id = task.id,
repo = %task.repo,
dispatch_key = format!("{}/{external_id}", task.repo),
age_mins = timing.age.num_minutes(),
threshold_mins = timing.threshold / 60,
"recovering cross-repo stuck task: no session found — reclaiming early → new"
);
}
if let Err(e) = store_set_by_id(
Some(store),
task.id,
&[
("agent", serde_json::Value::Null),
("model", serde_json::Value::Null),
("route_attempts", serde_json::json!(0)),
],
)
.await
{
tracing::warn!(
task_id = task.id,
repo = %task.repo,
?e,
"failed to clear routing fields for cross-repo stuck task — will retry next tick"
);
continue;
}
finalize_open_runs_for_recovery(
store,
task.id,
if timing.has_session {
"stuck-task recovery: cross-repo active session killed"
} else {
"stuck-task recovery: cross-repo no session found"
},
)
.await;
if let Err(e) = store.update_status(task.id, TaskStatus::New).await {
tracing::warn!(
task_id = task.id,
repo = %task.repo,
?e,
"failed to reset cross-repo stuck task status"
);
continue;
}
let details = serde_json::json!({
"failure_reason": if timing.has_session { "stuck_with_session" } else { "stuck_no_session" },
"age_minutes": timing.age.num_minutes(),
"had_session": timing.has_session,
"scope": "cross_repo_global_sweep",
});
store::store_log_activity(
&Some(Arc::clone(store)),
&task.repo,
&external_id,
"rerouted",
Some("in_progress"),
Some("new"),
None::<&str>,
None::<&str>,
Some(&details),
)
.await;
}
}
pub(crate) async fn tick_global_sweeps(
task_manager: &Arc<TaskManager>,
store: &Arc<TaskStore>,
router: &Router,
dispatching: &Arc<DashMap<String, String>>,
auto_merge_in_flight: &Arc<DashSet<String>>,
) {
if let Err(e) = sync::auto_unblock_ci_failure_blocked_tasks_global(
task_manager,
store,
auto_merge_in_flight,
)
.await
{
tracing::warn!(err = %e, "global CI-failure blocked sweep failed");
}
if let Err(e) = sync::refire_and_escalate_stale_needs_review_global(
task_manager,
store,
router,
dispatching,
)
.await
{
tracing::warn!(err = %e, "global NeedsReview refire/escalation sweep failed");
}
if let Err(e) =
sync::auto_recover_rebroadcast_blocked_tasks_global(router, task_manager, store).await
{
tracing::warn!(err = %e, "global rebroadcast-blocked recovery sweep failed");
}
}
pub(crate) async fn tick_route_tasks(
backend: &Arc<dyn ExternalBackend>,
task_manager: &Arc<TaskManager>,
router: &mut Router,
store: &Arc<TaskStore>,
repo: &str,
routing_quota: &mut RoutingTickQuota,
) -> anyhow::Result<()> {
let _span = tracing::info_span!("engine.tick.phase3a.route").entered();
if let Err(err) = dependencies::apply_claim_gates(store).await {
tracing::warn!(err = %err, "dependency claim gate failed");
}
if routing_quota.is_exhausted() {
tracing::debug!("routing quota exhausted for this tick — skipping repo routing phase");
return Ok(());
}
if is_github_circuit_open() {
let remaining = github_circuit_remaining_secs();
tracing::info!(
remaining_secs = remaining,
"GitHub 5xx circuit breaker open — skipping routing phase"
);
return Ok(());
}
let new_tasks = task_manager.list_routable().await?;
let routable: Vec<&ExternalTask> = new_tasks
.iter()
.filter(|t| !skips_agent(&t.labels))
.collect();
if !routable.is_empty() {
let start = Instant::now();
router.refresh_health(store).await;
tracing::debug!(
duration_ms = start.elapsed().as_millis(),
"router.refresh_health completed"
);
}
for task in routable.into_iter().take(routing_quota.remaining()) {
if !routing_quota.consume_one() {
break;
}
let _task_span = tracing::info_span!("engine.route", task_id = %task.id.0).entered();
let task_start = Instant::now();
match router.route(task, store, repo).await {
Ok(result) => {
tracing::debug!(task_id = %task.id.0, duration_ms = task_start.elapsed().as_millis(), "route completed");
if let Err(e) = router
.store_route_result(&task.id.0, &result, store, repo)
.await
{
tracing::warn!(
task_id = task.id.0,
?e,
"failed to store route result; skipping Routed transition"
);
continue;
}
if is_internal_id(&task.id.0) {
if let Err(e) = task_manager
.update_task_status(&task.id, Status::Routed)
.await
{
tracing::warn!(
task_id = task.id.0,
?e,
"failed to set internal status:routed"
);
continue;
}
} else {
let backend_clone = Arc::clone(backend);
let task_id_clone = task.id.clone();
let task_labels: Vec<String> = task.labels.clone();
let new_labels = {
let mut labels = vec![
format!("agent:{}", result.agent),
format!("complexity:{}", result.complexity),
];
if let Some(ref model) = result.model {
labels.push(format!("model:{model}"));
}
labels
};
tokio::spawn(async move {
for label in &task_labels {
if (label.starts_with("agent:")
|| label.starts_with("complexity:")
|| label.starts_with("model:"))
&& let Err(e) =
backend_clone.remove_label(&task_id_clone, label).await
{
tracing::warn!(task_id = task_id_clone.0, label, error = %e, "failed to remove stale routing label during re-route");
}
}
if let Err(e) = backend_clone.set_labels(&task_id_clone, &new_labels).await
{
tracing::warn!(
task_id = task_id_clone.0,
?e,
"failed to set routing labels"
);
}
});
if let Err(e) = task_manager
.update_task_status(&task.id, Status::Routed)
.await
{
tracing::warn!(task_id = task.id.0, ?e, "failed to set status:routed");
continue;
}
}
match store.ensure_external_task(repo, task).await {
Ok(store_id) => {
let profile_json =
serde_json::to_string(&result.profile).unwrap_or_default();
let skills_json =
serde_json::to_string(&result.selected_skills).unwrap_or_default();
if let Err(e) = store
.store_route(&crate::store::StoreRoute {
id: store_id,
agent: &result.agent,
model: result.model.as_deref(),
complexity: &result.complexity,
estimate: result.estimate,
reason: &result.reason,
profile: &profile_json,
skills: &skills_json,
})
.await
{
tracing::debug!(
task_id = task.id.0,
?e,
"dual-write: store_route failed"
);
}
if let Err(e) = store.update_status(store_id, TaskStatus::Routed).await {
tracing::debug!(
task_id = task.id.0,
?e,
"dual-write: update_status failed"
);
}
if result.estimate > 0 {
let backend_clone = Arc::clone(backend);
let task_id = task.id.clone();
let estimate = result.estimate;
tokio::spawn(async move {
if let Err(e) = backend_clone
.sync_estimate_to_project(&task_id, estimate)
.await
{
tracing::debug!(
task_id = task_id.0,
estimate,
err = %e,
"dual-write: estimate project sync failed"
);
}
});
}
}
Err(e) => {
tracing::debug!(
task_id = task.id.0,
?e,
"dual-write: ensure_external_task failed"
);
}
}
if let Some(ref warning) = result.warning {
tracing::warn!(task_id = task.id.0, warning, "routing sanity warning");
}
tracing::info!(
task_id = task.id.0,
agent = %result.agent,
complexity = %result.complexity,
reason = %result.reason,
"task routed"
);
}
Err(e) => {
if let Some(cooled) = e.downcast_ref::<AllAgentsCooledError>() {
tracing::error!(
task_id = %task.id.0,
scope = %cooled.scope(),
remaining_secs = ?cooled.remaining_secs(),
degraded = ?cooled.degraded(),
"ALL AGENTS COOLED — no agent available to route this task. \
Task stays in 'new' and will be retried on every tick. \
Check `orch cooldown list` to see what is blocking dispatch."
);
continue;
}
if let Some(waiting) = e.downcast_ref::<WaitingOnDependency>() {
tracing::info!(
task_id = %task.id.0,
blockers = %waiting.blockers.join(", "),
"task is waiting on a dependency"
);
continue;
}
tracing::error!(task_id = task.id.0, ?e, "routing failed, skipping task");
}
}
}
Ok(())
}
pub(crate) async fn route_new_tasks_global(
router: &mut Router,
store: &Arc<TaskStore>,
active_repos: &HashSet<String>,
routing_quota: &mut RoutingTickQuota,
) -> anyhow::Result<()> {
if routing_quota.is_exhausted() {
tracing::debug!("routing quota exhausted for this tick — skipping global routing sweep");
return Ok(());
}
let all_new = store.list_all_by_status_global(TaskStatus::New).await?;
let orphaned: Vec<&Task> = all_new
.iter()
.filter(|t| !active_repos.contains(&t.repo))
.filter(|t| !skips_agent(&t.labels))
.collect();
if orphaned.is_empty() {
return Ok(());
}
tracing::info!(
count = orphaned.len(),
"global routing sweep: found new tasks from inactive repos"
);
router.refresh_health(store).await;
for task in orphaned.into_iter().take(routing_quota.remaining()) {
if !routing_quota.consume_one() {
break;
}
let ext_task = tasks::store_task_to_external(task);
let task_start = Instant::now();
match router.route(&ext_task, store, &task.repo).await {
Ok(result) => {
tracing::debug!(
task_id = %ext_task.id.0,
repo = %task.repo,
duration_ms = task_start.elapsed().as_millis(),
"global sweep: route completed"
);
let profile_json = serde_json::to_string(&result.profile).unwrap_or_default();
let skills_json =
serde_json::to_string(&result.selected_skills).unwrap_or_default();
if let Err(e) = store
.store_route(&StoreRoute {
id: task.id,
agent: &result.agent,
model: result.model.as_deref(),
complexity: &result.complexity,
estimate: result.estimate,
reason: &result.reason,
profile: &profile_json,
skills: &skills_json,
})
.await
{
tracing::warn!(
task_id = %ext_task.id.0,
repo = %task.repo,
err = %e,
"global sweep: failed to store route result; skipping status update"
);
continue;
}
match store.update_status(task.id, TaskStatus::Routed).await {
Err(e) => {
tracing::warn!(
task_id = %ext_task.id.0,
repo = %task.repo,
err = %e,
"global sweep: failed to set status:routed"
);
}
_ => {
tracing::info!(
task_id = %ext_task.id.0,
repo = %task.repo,
agent = %result.agent,
complexity = %result.complexity,
"global sweep: task routed"
);
}
}
if let Some(ref warning) = result.warning {
tracing::warn!(task_id = %ext_task.id.0, warning, "global sweep: routing sanity warning");
}
}
Err(e) => {
if let Some(cooled) = e.downcast_ref::<AllAgentsCooledError>() {
tracing::error!(
task_id = %ext_task.id.0,
repo = %task.repo,
scope = %cooled.scope(),
remaining_secs = ?cooled.remaining_secs(),
degraded = ?cooled.degraded(),
"global sweep: ALL AGENTS COOLED — task stays in 'new'"
);
continue;
}
if let Some(waiting) = e.downcast_ref::<WaitingOnDependency>() {
tracing::info!(
task_id = %ext_task.id.0,
repo = %task.repo,
blockers = %waiting.blockers.join(", "),
"global sweep: task is waiting on a dependency"
);
continue;
}
tracing::error!(
task_id = %ext_task.id.0,
repo = %task.repo,
err = ?e,
"global sweep: routing failed, skipping task"
);
}
}
}
Ok(())
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct DispatchMode {
pub(crate) is_degraded: bool,
pub(crate) healthy_agents: usize,
pub(crate) threshold: usize,
}
pub(crate) fn dispatch_mode_from_router(router: &Router) -> DispatchMode {
let threshold = router_config::min_healthy_agents_threshold();
let healthy_agents = router.healthy_agent_count("simple");
let is_degraded = router.is_degraded(threshold);
DispatchMode {
is_degraded,
healthy_agents,
threshold,
}
}
#[expect(
clippy::too_many_arguments,
reason = "each argument is engine state the tick passes down"
)]
#[tracing::instrument(skip_all, name = "engine.tick.phase3b.dispatch")]
pub(crate) async fn tick_dispatch_tasks(
backend: &Arc<dyn ExternalBackend>,
tmux: &Arc<TmuxManager>,
repo: &str,
runner: &Arc<TaskRunner>,
capture: &Arc<CaptureService>,
semaphore: &Arc<Semaphore>,
task_manager: &Arc<TaskManager>,
weight_tx: &mpsc::Sender<WeightSignal>,
dispatch_mode: DispatchMode,
dispatching: &Arc<DashMap<String, String>>,
store: &Arc<TaskStore>,
session_map: &HashMap<String, bool>,
) -> anyhow::Result<()> {
let mut routed_tasks = task_manager.list_external_by_status(Status::Routed).await?;
let internal_routed = task_manager
.list_internal_by_status(DbStatus::Routed)
.await?;
routed_tasks.extend(internal_routed);
let dispatchable: Vec<ExternalTask> = routed_tasks
.into_iter()
.filter(|t| !skips_agent(&t.labels))
.collect();
dispatch_tasks_for_repo(
backend,
tmux,
repo,
runner,
capture,
semaphore,
task_manager,
weight_tx,
dispatch_mode,
dispatching,
store,
session_map,
dispatchable,
)
.await
}
#[expect(
clippy::too_many_arguments,
reason = "each argument is engine state the tick passes down"
)]
async fn dispatch_tasks_for_repo(
backend: &Arc<dyn ExternalBackend>,
tmux: &Arc<TmuxManager>,
repo: &str,
runner: &Arc<TaskRunner>,
capture: &Arc<CaptureService>,
semaphore: &Arc<Semaphore>,
task_manager: &Arc<TaskManager>,
weight_tx: &mpsc::Sender<WeightSignal>,
dispatch_mode: DispatchMode,
dispatching: &Arc<DashMap<String, String>>,
store: &Arc<TaskStore>,
session_map: &HashMap<String, bool>,
dispatchable: Vec<ExternalTask>,
) -> anyhow::Result<()> {
if dispatchable.is_empty() {
tracing::debug!(count = 0, "dispatchable tasks found");
return Ok(());
}
tracing::info!(count = dispatchable.len(), "dispatchable tasks found");
if dispatch_mode.is_degraded {
tracing::warn!(
healthy_agents = dispatch_mode.healthy_agents,
threshold = dispatch_mode.threshold,
"degraded mode: using sequential dispatch"
);
}
let sequential_delay = if dispatch_mode.is_degraded {
router_config::sequential_dispatch_delay_ms()
} else {
0
};
let mut ranked: Vec<(Priority, ExternalTask)> = Vec::with_capacity(dispatchable.len());
for task in dispatchable {
let has_pr = match store.resolve_id_by_external(repo, &task.id.0).await {
Ok(Some(id)) => store.get(id).await.is_ok_and(|t| t.pr_number.is_some()),
_ => false,
};
ranked.push((Priority::from_labels(&task.labels).with_pr(has_pr), task));
}
priority::sort_by_priority(&mut ranked, |t| t.0);
for (idx, (priority, task)) in ranked.into_iter().enumerate() {
if dependencies::dispatch_is_waiting(store, repo, &task.id.0).await {
continue;
}
if idx > 0 && dispatch_mode.is_degraded {
let delay_ms = sequential_delay;
tracing::debug!(
task_id = task.id.0,
delay_ms = delay_ms,
"sequential dispatch: waiting before dispatch"
);
time::sleep(Duration::from_millis(delay_ms)).await;
}
let dispatch_key = format!("{}/{}", repo, task.id.0);
{
match dispatching.entry(dispatch_key.clone()) {
Entry::Occupied(existing) => {
let existing_id = existing.get().clone();
drop(existing); if existing_id == task.id.0 {
tracing::debug!(
task_id = task.id.0,
"task already dispatching, skipping duplicate"
);
} else {
tracing::warn!(
task_id = task.id.0,
existing_task_id = existing_id,
dispatch_key,
"dispatch key collision: unexpected task already holds this key"
);
}
continue;
}
Entry::Vacant(slot) => {
slot.insert(task.id.0.clone());
tracing::info!(task_id = task.id.0, dispatch_key, "dispatch guard claimed");
}
}
}
let dispatch_guard = DispatchGuard::new(Arc::clone(dispatching), dispatch_key.clone());
let task_id = task.id.0.clone();
let route_result = match get_route_result(store, repo, &task_id).await {
Ok(r) => Some(r),
Err(RouteResultError::NoAgent { .. }) => {
tracing::warn!(
task_id,
"routed task missing agent — resetting to new for re-routing (#1604)"
);
if let Err(e2) = task_manager.update_task_status(&task.id, Status::New).await {
tracing::error!(task_id, error = %e2, "failed to reset task to new");
}
continue; }
Err(e) => {
tracing::warn!(task_id, error = %e, "get_route_result failed — dispatching without route info");
None
}
};
if let Some(route) = route_result.as_ref() {
let agent_cooled = is_agent_in_cooldown(&route.agent);
let model_cooled = route
.model
.as_deref()
.is_some_and(|model| is_model_in_cooldown(&route.agent, model));
if agent_cooled || model_cooled {
tracing::warn!(
task_id,
agent = route.agent,
model = route.model.as_deref().unwrap_or(""),
"routed agent/model entered cooldown after routing — deferring dispatch to next tick"
);
continue; }
}
let session_name = tmux.session_name(repo, &task_id);
if tmux
.session_blocks_dispatch_from_map(&session_name, session_map)
.await
{
tracing::warn!(
task_id,
session_name,
"task has existing tmux session, skipping dispatch"
);
continue; }
let Some(permit) = priority::acquire_dispatch_slot(semaphore, priority) else {
tracing::debug!("all parallel slots busy, skipping remaining tasks");
break; };
let set_in_progress_result = task_manager
.update_task_status(&task.id, Status::InProgress)
.await;
if let Err(e) = set_in_progress_result {
tracing::error!(task_id, ?e, "failed to set in_progress, skipping dispatch");
drop(permit);
continue; }
tracing::info!(task_id, "dispatching task");
capture
.register_session(repo, &task_id, &session_name)
.await;
let runner = Arc::clone(runner);
let backend = Arc::clone(backend);
let tmux = Arc::clone(tmux);
let capture = Arc::clone(capture);
let task_id_for_cleanup = task_id.clone();
let task_owned = task;
let weight_tx = weight_tx.clone();
let repo_owned = repo.to_string();
let task_manager_for_spawn = Arc::clone(task_manager);
let store_for_spawn = Arc::clone(store);
let repo_ctx = repo_owned.clone();
tokio::spawn(REPO_CONTEXT.scope(repo_ctx, async move {
let _dispatch_guard = dispatch_guard; let dispatch_start = Instant::now();
match runner
.run_with_context(&task_owned, &backend, &tmux, route_result.as_ref())
.await
{
Ok(signal) => {
tracing::info!(task_id, "task runner completed");
let duration = dispatch_start.elapsed().as_secs_f64();
let enable_review = config::get("workflow.enable_review_agent")
.map_or(true, |v| v != "false");
let has_pr = if enable_review && matches!(signal, WeightSignal::Success { .. })
{
match store_for_spawn.resolve_task_id(&repo_owned, &task_id).await {
Ok(Some(store_id)) => match store_for_spawn.get(store_id).await {
Ok(t) => t.pr_number.is_some(),
Err(e) => {
tracing::warn!(task_id, err = %e, "store.get() failed — defaulting to needs_review");
true
}
},
Ok(None) => false,
Err(e) => {
tracing::warn!(task_id, err = %e, "failed to check PR status — defaulting to needs_review");
true
}
}
} else {
false
};
let display_status = display_status_for(&signal, enable_review, has_pr);
if display_status == "done" {
tracing::info!(
task_id,
"marking done: the run succeeded with no PR to review"
);
}
tracing::info!(task_id, display_status, "task run finished, applying status");
let _ = weight_tx.send(signal).await;
if display_status == "needs_review" {
if let Err(e) = task_manager_for_spawn
.update_task_status_with_duration(
&ExternalId(task_id.clone()),
Status::NeedsReview,
Some(duration),
)
.await
{
tracing::error!(task_id, err = %e, "update_task_status(NeedsReview) failed — task may be stuck");
}
} else {
let final_status = match display_status {
"done" => Status::Done,
"blocked" => Status::Blocked,
_ => Status::New,
};
if let Err(e) = task_manager_for_spawn
.update_task_status_with_duration(
&ExternalId(task_id.clone()),
final_status,
Some(duration),
)
.await
{
tracing::warn!(task_id, ?e, "failed to update task status after completion");
}
}
}
Err(e) => {
tracing::error!(task_id, ?e, "task runner failed");
let duration = dispatch_start.elapsed().as_secs_f64();
if is_internal_id(&task_id) {
if let Err(ue) = task_manager_for_spawn
.update_task_status_with_duration(
&ExternalId(task_id.clone()),
Status::NeedsReview,
Some(duration),
)
.await
{
tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
}
} else {
if let Err(comment_err) = backend
.post_comment(
&ExternalId(task_id.clone()),
&format!(
"[{}] error: task runner failed: {e}{}",
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
engine::orch_footer()
),
)
.await
{
tracing::warn!(
task_id,
?comment_err,
"failed to post error comment to GitHub"
);
}
if let Err(ue) = task_manager_for_spawn
.update_task_status_with_duration(
&ExternalId(task_id.clone()),
Status::NeedsReview,
Some(duration),
)
.await
{
tracing::error!(task_id, err = %ue, "update_task_status(NeedsReview) failed — task may be stuck");
}
}
}
}
capture.unregister_session(&repo_owned, &task_id_for_cleanup).await;
drop(permit);
}));
}
Ok(())
}
fn skips_agent(labels: &[String]) -> bool {
labels
.iter()
.any(|label| label == "no-agent" || label == "owner:human")
}
fn group_orphaned_routed_tasks(
all_routed: Vec<Task>,
active_repos: &HashSet<String>,
) -> HashMap<String, Vec<Task>> {
let mut by_repo: HashMap<String, Vec<Task>> = HashMap::new();
for task in all_routed
.into_iter()
.filter(|t| !active_repos.contains(&t.repo))
.filter(|t| !skips_agent(&t.labels))
{
by_repo.entry(task.repo.clone()).or_default().push(task);
}
by_repo
}
#[expect(
clippy::too_many_arguments,
reason = "each argument is engine state the tick passes down"
)]
pub(crate) async fn dispatch_routed_tasks_global(
tmux: &Arc<TmuxManager>,
capture: &Arc<CaptureService>,
semaphore: &Arc<Semaphore>,
weight_tx: &mpsc::Sender<WeightSignal>,
dispatch_mode: DispatchMode,
dispatching: &Arc<DashMap<String, String>>,
store: &Arc<TaskStore>,
session_map: &HashMap<String, bool>,
active_repos: &HashSet<String>,
) -> anyhow::Result<()> {
let all_routed = store.list_all_by_status_global(TaskStatus::Routed).await?;
let by_repo = group_orphaned_routed_tasks(all_routed, active_repos);
if by_repo.is_empty() {
return Ok(());
}
tracing::info!(
repos = by_repo.len(),
"global dispatch sweep: found routed tasks from inactive repos"
);
for (repo, tasks) in by_repo {
let backend: Arc<dyn ExternalBackend> = match GitHubBackend::new(repo.clone()) {
Ok(b) => Arc::new(b),
Err(e) => {
tracing::warn!(repo = %repo, err = %e, "global sweep: failed to construct backend, skipping repo");
continue;
}
};
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(store),
repo.clone(),
));
let runner = Arc::new(TaskRunner::new(repo.clone()).with_store(Arc::clone(store)));
let dispatchable: Vec<ExternalTask> =
tasks.iter().map(tasks::store_task_to_external).collect();
if let Err(e) = dispatch_tasks_for_repo(
&backend,
tmux,
&repo,
&runner,
capture,
semaphore,
&task_manager,
weight_tx,
dispatch_mode,
dispatching,
store,
session_map,
dispatchable,
)
.await
{
tracing::warn!(repo = %repo, err = %e, "global sweep: dispatch failed for repo");
}
}
Ok(())
}
pub(crate) async fn tick_unblock_parents(
backend: &Arc<dyn ExternalBackend>,
task_manager: &Arc<TaskManager>,
store: &Arc<TaskStore>,
repo: &str,
) -> anyhow::Result<()> {
let blocked = task_manager.list_all_by_status(Status::Blocked).await?;
let store_blocked = store.list_by_status(repo, TaskStatus::Blocked).await?;
let has_block_reason: HashSet<String> = store_blocked
.iter()
.filter(|t| t.block_reason.is_some())
.filter_map(|t| t.external_id.clone())
.collect();
let handles: Vec<_> = blocked
.into_iter()
.filter(|task| {
if has_block_reason.contains(&task.id.0) {
tracing::debug!(
task_id = task.id.0,
"skipping blocked task with known block_reason"
);
false
} else {
true
}
})
.map(|task| {
let backend_clone = Arc::clone(backend);
let task_manager_clone = Arc::clone(task_manager);
let store_clone = Arc::clone(store);
let repo_owned = repo.to_string();
tokio::spawn(async move {
let task_id = &task.id;
let mut children: Vec<ExternalId> = Vec::new();
let mut child_statuses: HashMap<
String,
TaskStatus,
> = HashMap::new();
match store_clone.resolve_task_id(&repo_owned, &task_id.0).await {
Ok(Some(parent_store_id)) => {
match store_clone.list_children(&repo_owned, parent_store_id).await {
Ok(store_children) => {
for child in store_children {
let Some(child_external_id) = child.external_id else {
continue;
};
child_statuses.insert(child_external_id.clone(), child.status);
children.push(ExternalId(child_external_id));
}
}
Err(e) => {
tracing::debug!(
task_id = task_id.0,
?e,
"failed to list store-linked children"
);
}
}
}
Ok(None) => {}
Err(e) => {
tracing::debug!(
task_id = task_id.0,
?e,
"failed to resolve blocked task in store for child lookup"
);
}
}
if !is_internal_id(&task_id.0) {
match backend_clone.get_sub_issues(task_id).await {
Ok(ids) => {
let mut seen: HashSet<String> =
children.iter().map(|child| child.0.clone()).collect();
for id in ids {
if seen.insert(id.0.clone()) {
children.push(id);
}
}
}
Err(e) => {
tracing::debug!(task_id = task_id.0, ?e, "failed to get sub-issues");
}
}
}
if children.is_empty() {
return;
}
let mut all_done = true;
let child_ext_strs: Vec<String> =
children.iter().map(|c| c.0.clone()).collect();
let child_exts: Vec<&str> =
child_ext_strs.iter().map(String::as_str).collect();
match store_clone
.get_statuses_by_external_ids(&repo_owned, &child_exts)
.await
{ Ok(map) => {
for (k, v) in map {
child_statuses.insert(k, v);
}
} _ => {
tracing::debug!(
task_id = task_id.0,
"failed to batch-query store for child statuses; falling back to per-child lookups"
);
}}
for child_id in &children {
if let Some(status) = child_statuses.get(&child_id.0) {
if *status != TaskStatus::Done {
all_done = false;
break;
}
continue;
}
match backend_clone.get_task(child_id).await {
Ok(child) => {
if !child.labels.iter().any(|l| l == Status::Done.as_label()) {
all_done = false;
break;
}
}
Err(e) => {
tracing::debug!(task_id = task_id.0, child = %child_id.0, ?e, "failed to fetch child task from backend — treating as not done");
all_done = false;
break;
}
}
}
if all_done {
tracing::info!(
task_id = task_id.0,
children = children.len(),
"all children done, unblocking parent"
);
if let Err(e) =
task_manager_clone.update_task_status(task_id, Status::New).await
{
tracing::warn!(task_id = task_id.0, ?e, "failed to unblock parent");
}
}
})
})
.collect();
for handle in handles {
if let Err(e) = handle.await {
tracing::warn!(?e, "phase 4 sub-issue check panicked");
}
}
Ok(())
}
pub(crate) async fn tick_job_scheduler(
jobs_path: &Path,
backend: &Arc<dyn ExternalBackend>,
store: Option<&Arc<TaskStore>>,
repo: &str,
transport: Option<&Arc<Transport>>,
) -> anyhow::Result<()> {
jobs::tick(jobs_path, backend, store, repo, transport).await
}
#[expect(
clippy::too_many_arguments,
reason = "each argument is engine state the tick passes down"
)]
pub(crate) async fn tick_before_routing(
backend: &Arc<dyn ExternalBackend>,
tmux: &Arc<TmuxManager>,
repo: &str,
capture: &Arc<CaptureService>,
config: &EngineConfig,
task_manager: &Arc<TaskManager>,
dispatching: &Arc<DashMap<String, String>>,
store: &Arc<TaskStore>,
session_map: &HashMap<String, bool>,
) {
let mut phases: Vec<(&str, u128)> = Vec::new();
let mut at = Instant::now();
if let Err(e) = tick_check_session_completions(tmux, repo, capture, store).await {
tracing::warn!(err = %e, "session completion phase failed");
}
phases.push(("sessions", at.elapsed().as_millis()));
if !store.is_leader() {
super::log_phase_timings("tick.pre_routing", repo, &phases);
return;
}
at = Instant::now();
if let Err(e) =
tick_detect_silent_agents(tmux, repo, capture, backend, task_manager, config, store).await
{
tracing::warn!(err = %e, "silent-agent detection phase failed");
}
phases.push(("silent_agents", at.elapsed().as_millis()));
at = Instant::now();
if let Err(e) = tick_recover_stuck_tasks(
backend,
tmux,
repo,
task_manager,
config,
store,
session_map,
dispatching,
)
.await
{
tracing::warn!(err = %e, "stuck-task recovery phase failed");
}
phases.push(("stuck_tasks", at.elapsed().as_millis()));
super::log_phase_timings("tick.pre_routing", repo, &phases);
}
#[expect(
clippy::too_many_arguments,
reason = "each argument is engine state the tick passes down"
)]
pub(crate) async fn tick_after_routing(
backend: &Arc<dyn ExternalBackend>,
tmux: &Arc<TmuxManager>,
repo: &str,
runner: &Arc<TaskRunner>,
capture: &Arc<CaptureService>,
semaphore: &Arc<Semaphore>,
project_dir: &Path,
task_manager: &Arc<TaskManager>,
weight_tx: &mpsc::Sender<WeightSignal>,
dispatch_mode: DispatchMode,
dispatching: &Arc<DashMap<String, String>>,
store: &Arc<TaskStore>,
transport: Option<&Arc<Transport>>,
session_map: &HashMap<String, bool>,
) {
let mut phases: Vec<(&str, u128)> = Vec::new();
let mut at = Instant::now();
if let Err(e) = tick_dispatch_tasks(
backend,
tmux,
repo,
runner,
capture,
semaphore,
task_manager,
weight_tx,
dispatch_mode,
dispatching,
store,
session_map,
)
.await
{
tracing::warn!(err = %e, "dispatch phase failed");
}
phases.push(("dispatch", at.elapsed().as_millis()));
at = Instant::now();
if let Err(e) = tick_unblock_parents(backend, task_manager, store, repo).await {
tracing::warn!(err = %e, "parent-unblock phase failed");
}
phases.push(("unblock_parents", at.elapsed().as_millis()));
at = Instant::now();
let jobs_path = project_dir.join(".orch.yml");
if let Err(e) = tick_job_scheduler(&jobs_path, backend, Some(store), repo, transport).await {
tracing::error!(?e, "job scheduler tick failed");
}
phases.push(("jobs", at.elapsed().as_millis()));
super::log_phase_timings("tick.post_routing", repo, &phases);
}
#[expect(
clippy::too_many_arguments,
reason = "each argument is engine state the tick passes down"
)]
pub(crate) async fn tick_all(
engines: &[ProjectEngine],
tmux: &Arc<TmuxManager>,
capture: &Arc<CaptureService>,
semaphore: &Arc<Semaphore>,
config: &EngineConfig,
router: &Arc<RwLock<Router>>,
weight_tx: &mpsc::Sender<WeightSignal>,
dispatching: &Arc<DashMap<String, String>>,
transport: Option<&Arc<Transport>>,
active_repos: &HashSet<String>,
auto_merge_in_flight: &Arc<DashSet<String>>,
) {
let _tick_span = tracing::info_span!("engine.tick").entered();
let Some(first) = engines.first() else {
return;
};
let store = &first.store;
for (project, call_type, calls) in http::drain_call_counts().into_iter().take(10) {
tracing::debug!(
project,
call_type,
calls,
"github api calls since last tick"
);
}
let session_map = tmux.batch_session_active().await;
let mut passes = Vec::with_capacity(engines.len());
for engine in engines {
passes.push(REPO_CONTEXT.scope(
engine.repo.clone(),
tick_before_routing(
&engine.backend,
tmux,
&engine.repo,
capture,
config,
&engine.task_manager,
dispatching,
&engine.store,
&session_map,
),
));
}
future::join_all(passes).await;
if !store.is_leader() {
return;
}
let at = Instant::now();
{
let router_guard = router.read().await;
tick_global_sweeps(
&first.task_manager,
store,
&router_guard,
dispatching,
auto_merge_in_flight,
)
.await;
}
let sweeps_ms = at.elapsed().as_millis();
let at = Instant::now();
recover_untracked_repo_stuck_tasks(
tmux,
config,
store,
&session_map,
dispatching,
active_repos,
)
.await;
let untracked_ms = at.elapsed().as_millis();
let at = Instant::now();
if let Err(err) = dependencies::reconcile_dependencies(
first.backend.as_ref(),
store,
transport.map(AsRef::as_ref),
)
.await
{
tracing::warn!(err = %err, "dependency reconcile failed");
}
super::log_phase_timings(
"tick.global",
&first.repo,
&[
("global_sweeps", sweeps_ms),
("untracked_stuck", untracked_ms),
("dependencies", at.elapsed().as_millis()),
],
);
{
let mut router_guard = router.write().await;
let mut routing_quota = RoutingTickQuota::new(router_config::max_tasks_per_routing_tick());
for engine in engines {
let at = Instant::now();
REPO_CONTEXT
.scope(engine.repo.clone(), async {
if let Err(e) = tick_route_tasks(
&engine.backend,
&engine.task_manager,
&mut router_guard,
&engine.store,
&engine.repo,
&mut routing_quota,
)
.await
{
tracing::warn!(err = %e, "routing phase failed");
}
})
.await;
super::log_phase_timings(
"tick.routing",
&engine.repo,
&[("route", at.elapsed().as_millis())],
);
}
if let Err(e) =
route_new_tasks_global(&mut router_guard, store, active_repos, &mut routing_quota).await
{
tracing::warn!(err = %e, "global new-task routing sweep failed");
}
}
let dispatch_mode = dispatch_mode_from_router(&*router.read().await);
let session_map = tmux.batch_session_active().await;
let mut passes = Vec::with_capacity(engines.len());
for engine in engines {
passes.push(REPO_CONTEXT.scope(
engine.repo.clone(),
tick_after_routing(
&engine.backend,
tmux,
&engine.repo,
&engine.runner,
capture,
semaphore,
&engine.project_dir,
&engine.task_manager,
weight_tx,
dispatch_mode,
dispatching,
&engine.store,
transport,
&session_map,
),
));
}
future::join_all(passes).await;
if let Err(e) = dispatch_routed_tasks_global(
tmux,
capture,
semaphore,
weight_tx,
dispatch_mode,
dispatching,
store,
&session_map,
active_repos,
)
.await
{
tracing::warn!(err = %e, "global routed-task dispatch sweep failed");
}
}
pub(crate) fn display_status_for(
signal: &WeightSignal,
enable_review: bool,
has_pr: bool,
) -> &'static str {
match signal {
WeightSignal::Success { .. } if enable_review => {
if has_pr {
"needs_review"
} else {
"done"
}
}
WeightSignal::RateLimited { .. } | WeightSignal::Rerouted => "new",
WeightSignal::Blocked => "blocked",
WeightSignal::Failed { .. } => "needs_review",
WeightSignal::None if enable_review => "needs_review",
WeightSignal::Success { .. } | WeightSignal::None => "done",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backends::{ExternalId, ExternalTask, Mention, Status};
use crate::channels::transport::Transport;
use crate::engine::RunTimeouts;
use crate::engine::cooldown;
use crate::engine::router::Router;
use crate::engine::router::RouterConfig;
use crate::engine::tasks::TaskManager;
use crate::store::DepSource;
use crate::store::NewDependency;
use crate::store::NewTask;
use crate::store::StoreRoute;
use crate::store::TaskStatus as DbStatus;
use crate::store::TaskStatus;
use crate::store::TaskStore;
use crate::store::UpsertExternal;
use crate::tmux::{SessionGuard, TmuxManager};
use async_trait::async_trait;
use dashmap::mapref::entry::Entry;
use std::cell::Cell;
use std::fs;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tokio::sync::RwLock;
use tokio::sync::oneshot;
use tokio::task;
#[test]
fn timed_out_run_without_pr_never_becomes_done() {
let failed = WeightSignal::Failed {
agent: "claude".to_string(),
};
assert_eq!(display_status_for(&failed, true, false), "needs_review");
assert_eq!(display_status_for(&failed, false, false), "needs_review");
}
#[test]
fn done_needs_a_successful_run_with_no_pr() {
let ok = WeightSignal::Success {
agent: "claude".to_string(),
};
assert_eq!(display_status_for(&ok, true, true), "needs_review");
assert_eq!(display_status_for(&ok, true, false), "done");
assert_eq!(
display_status_for(&WeightSignal::Rerouted, true, false),
"new"
);
assert_eq!(
display_status_for(&WeightSignal::None, true, false),
"needs_review"
);
}
#[test]
fn skips_agent_for_human_and_no_agent_labels() {
assert!(skips_agent(&["owner:human".to_string()]));
assert!(skips_agent(&["no-agent".to_string()]));
assert!(!skips_agent(&["status:new".to_string()]));
}
fn cooled_review_router_for_test() -> Router {
Router::new_for_test(RouterConfig::default(), vec!["claude".to_string()])
}
struct MockBackend {
blocked_tasks: Vec<ExternalTask>,
sub_issues: HashMap<String, Vec<ExternalId>>,
tasks_by_id: HashMap<String, ExternalTask>,
get_sub_issues_calls: Arc<Mutex<Vec<String>>>,
status_updates: Arc<Mutex<Vec<(String, Status)>>>,
removed_labels: Arc<Mutex<Vec<(String, String)>>>,
posted_comments: Arc<Mutex<Vec<(String, String)>>>,
}
impl MockBackend {
fn new() -> Self {
Self {
blocked_tasks: vec![],
sub_issues: HashMap::new(),
tasks_by_id: HashMap::new(),
get_sub_issues_calls: Arc::new(Mutex::new(vec![])),
status_updates: Arc::new(Mutex::new(vec![])),
removed_labels: Arc::new(Mutex::new(vec![])),
posted_comments: Arc::new(Mutex::new(vec![])),
}
}
}
fn make_task(id: &str, labels: &[&str]) -> ExternalTask {
ExternalTask {
id: ExternalId(id.to_string()),
title: format!("Task {id}"),
body: String::new(),
state: "open".to_string(),
labels: labels.iter().map(ToString::to_string).collect(),
author: "bot".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
url: format!("https://github.com/test/test/issues/{id}"),
}
}
#[async_trait]
impl ExternalBackend for MockBackend {
fn name(&self) -> &'static str {
"mock"
}
async fn create_task(
&self,
_title: &str,
_body: &str,
_labels: &[String],
) -> anyhow::Result<ExternalId> {
Ok(ExternalId("new".to_string()))
}
async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
self.tasks_by_id
.get(&id.0)
.cloned()
.ok_or_else(|| anyhow::anyhow!("task not found: {}", id.0))
}
async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>> {
if status == Status::Blocked {
Ok(self.blocked_tasks.clone())
} else {
Ok(vec![])
}
}
async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
Ok(vec![])
}
async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
self.posted_comments
.lock()
.unwrap()
.push((_id.0.clone(), _body.to_string()));
Ok(())
}
async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
Ok(())
}
async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
self.removed_labels
.lock()
.unwrap()
.push((_id.0.clone(), _label.to_string()));
Ok(())
}
async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
self.get_sub_issues_calls.lock().unwrap().push(id.0.clone());
Ok(self.sub_issues.get(&id.0).cloned().unwrap_or_default())
}
async fn create_sub_task(
&self,
_parent: &ExternalId,
_title: &str,
_body: &str,
_labels: &[String],
) -> anyhow::Result<ExternalId> {
Ok(ExternalId("child".to_string()))
}
async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
Ok(())
}
async fn has_open_issue_with_title(
&self,
_title: &str,
_label: &str,
) -> anyhow::Result<bool> {
Ok(false)
}
async fn health_check(&self) -> anyhow::Result<()> {
Ok(())
}
async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
Ok(false)
}
async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
Ok(Some("testbot".to_string()))
}
async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
Ok(vec![])
}
async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
self.status_updates
.lock()
.unwrap()
.push((id.0.clone(), status));
Ok(())
}
}
fn make_task_manager(backend: Arc<dyn ExternalBackend>) -> Arc<TaskManager> {
Arc::new(TaskManager::new(backend))
}
#[tokio::test]
async fn unblock_parents_unblocks_when_all_children_done() {
let mut mock = MockBackend::new();
let parent = make_task("10", &["status:blocked"]);
mock.blocked_tasks.push(parent.clone());
mock.sub_issues.insert(
"10".to_string(),
vec![ExternalId("11".to_string()), ExternalId("12".to_string())],
);
mock.tasks_by_id
.insert("11".to_string(), make_task("11", &["status:done"]));
mock.tasks_by_id
.insert("12".to_string(), make_task("12", &["status:done"]));
let status_updates = Arc::clone(&mock.status_updates);
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = make_task_manager(Arc::clone(&backend));
let store = Arc::new(TaskStore::open_memory().await.unwrap());
tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
.await
.unwrap();
let updates = status_updates.lock().unwrap();
assert_eq!(updates.len(), 1, "parent should be unblocked");
assert_eq!(updates[0], ("10".to_string(), Status::New));
}
#[tokio::test]
async fn unblock_parents_skips_when_child_not_done() {
let mut mock = MockBackend::new();
let parent = make_task("20", &["status:blocked"]);
mock.blocked_tasks.push(parent.clone());
mock.sub_issues.insert(
"20".to_string(),
vec![ExternalId("21".to_string()), ExternalId("22".to_string())],
);
mock.tasks_by_id
.insert("21".to_string(), make_task("21", &["status:done"]));
mock.tasks_by_id
.insert("22".to_string(), make_task("22", &["status:in_progress"]));
let status_updates = Arc::clone(&mock.status_updates);
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = make_task_manager(Arc::clone(&backend));
let store = Arc::new(TaskStore::open_memory().await.unwrap());
tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
.await
.unwrap();
let updates = status_updates.lock().unwrap();
assert!(
updates.is_empty(),
"should not unblock when a child is still running"
);
}
#[tokio::test]
async fn unblock_parents_skips_task_with_no_children() {
let mut mock = MockBackend::new();
let parent = make_task("30", &["status:blocked"]);
mock.blocked_tasks.push(parent.clone());
let status_updates = Arc::clone(&mock.status_updates);
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = make_task_manager(Arc::clone(&backend));
let store = Arc::new(TaskStore::open_memory().await.unwrap());
tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
.await
.unwrap();
let updates = status_updates.lock().unwrap();
assert!(
updates.is_empty(),
"should not unblock tasks with no sub-issues"
);
}
#[tokio::test]
async fn unblock_parents_skips_task_with_block_reason_in_store() {
let mut mock = MockBackend::new();
let parent = make_task("60", &["status:blocked"]);
mock.blocked_tasks.push(parent.clone());
mock.sub_issues
.insert("60".to_string(), vec![ExternalId("61".to_string())]);
mock.tasks_by_id
.insert("61".to_string(), make_task("61", &["status:done"]));
let status_updates = Arc::clone(&mock.status_updates);
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = make_task_manager(Arc::clone(&backend));
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let task_id = store
.upsert_external(&crate::store::UpsertExternal {
repo: "test/repo",
ext_id: "60",
title: "Task 60",
body: "",
author: "bot",
url: "https://github.com/test/test/issues/60",
labels: &[],
origin: "github",
})
.await
.unwrap();
store
.update_status(task_id, TaskStatus::Blocked)
.await
.unwrap();
store
.set_block_reason(task_id, Some("CI failure limit reached"))
.await
.unwrap();
tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
.await
.unwrap();
let updates = status_updates.lock().unwrap();
assert!(
updates.is_empty(),
"task with block_reason should be skipped — no API call, no unblock"
);
}
#[tokio::test]
async fn unblock_parents_unblocks_internal_parent_from_store_children() {
let mock = MockBackend::new();
let get_sub_issues_calls = Arc::clone(&mock.get_sub_issues_calls);
let status_updates = Arc::clone(&mock.status_updates);
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"test/repo".to_string(),
));
let parent_id = store
.create_internal("test/repo", "Parent", "", "job", "daily", None)
.await
.unwrap();
store
.update_status(parent_id, TaskStatus::Blocked)
.await
.unwrap();
let child_id = store
.create(&NewTask {
external_id: Some("91".to_string()),
repo: "test/repo".to_string(),
origin: "github".to_string(),
title: "Child".to_string(),
body: String::new(),
source: String::new(),
source_id: String::new(),
author: String::new(),
url: String::new(),
labels: vec![],
parent_id: Some(parent_id),
})
.await
.unwrap();
store
.update_status(child_id, TaskStatus::Done)
.await
.unwrap();
tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
.await
.unwrap();
let parent = store.get(parent_id).await.unwrap();
assert_eq!(parent.status, TaskStatus::New);
assert!(
status_updates.lock().unwrap().is_empty(),
"internal parent should be updated in store without backend mirroring"
);
assert!(
get_sub_issues_calls.lock().unwrap().is_empty(),
"internal parent should not query backend sub-issues"
);
}
#[cfg(test)]
async fn set_task_updated_at_past(store: &TaskStore, task_id: i64) {
sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
.bind(task_id)
.execute(store.pool())
.await
.unwrap();
}
async fn set_in_review_entered_at_past(store: &TaskStore, task_id: i64) {
sqlx::query(
"UPDATE task_activity SET timestamp = '2020-01-01T00:00:00Z'
WHERE task_id = ? AND to_status = 'in_review'",
)
.bind(task_id)
.execute(store.pool())
.await
.unwrap();
}
#[tokio::test]
async fn recover_stuck_tasks_resets_external_in_review_to_needs_review() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let mock = MockBackend::new();
let status_updates = Arc::clone(&mock.status_updates);
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let config = EngineConfig {
no_session_stuck_timeout: 600,
stuck_timeout: 1800,
..EngineConfig::default()
};
let id = store
.upsert_external(&crate::store::UpsertExternal {
repo: "owner/repo",
ext_id: "99",
title: "Stuck InReview",
body: "",
author: "",
url: "",
labels: &[],
origin: "github",
})
.await
.unwrap();
store.update_status(id, TaskStatus::InReview).await.unwrap();
set_in_review_entered_at_past(&store, id).await;
set_task_updated_at_past(&store, id).await;
tick_recover_stuck_tasks(
&backend,
&tmux,
"owner/repo",
&task_manager,
&config,
&store,
&HashMap::new(),
&Arc::new(DashMap::new()),
)
.await
.unwrap();
let updates = status_updates.lock().unwrap();
assert_eq!(updates.len(), 1, "stuck InReview task should be recovered");
assert_eq!(updates[0].0, "99");
assert_eq!(updates[0].1, Status::NeedsReview);
}
#[tokio::test]
async fn recover_stuck_tasks_skips_recent_in_review() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let mock = MockBackend::new();
let status_updates = Arc::clone(&mock.status_updates);
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let config = EngineConfig {
no_session_stuck_timeout: 600, stuck_timeout: 1800,
..EngineConfig::default()
};
let id = store
.upsert_external(&crate::store::UpsertExternal {
repo: "owner/repo",
ext_id: "100",
title: "Recent InReview",
body: "",
author: "",
url: "",
labels: &[],
origin: "github",
})
.await
.unwrap();
store.update_status(id, TaskStatus::InReview).await.unwrap();
set_task_updated_at_past(&store, id).await;
tick_recover_stuck_tasks(
&backend,
&tmux,
"owner/repo",
&task_manager,
&config,
&store,
&HashMap::new(),
&Arc::new(DashMap::new()),
)
.await
.unwrap();
let updates = status_updates.lock().unwrap();
assert!(
updates.is_empty(),
"recent InReview task should not be recovered"
);
}
#[tokio::test]
async fn recover_stuck_tasks_resets_internal_in_review_to_needs_review() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let config = EngineConfig {
no_session_stuck_timeout: 600,
stuck_timeout: 1800,
..EngineConfig::default()
};
let id = store
.create_internal("owner/repo", "Internal InReview", "", "cron", "1", None)
.await
.unwrap();
store.update_status(id, TaskStatus::InReview).await.unwrap();
set_in_review_entered_at_past(&store, id).await;
set_task_updated_at_past(&store, id).await;
tick_recover_stuck_tasks(
&backend,
&tmux,
"owner/repo",
&task_manager,
&config,
&store,
&HashMap::new(),
&Arc::new(DashMap::new()),
)
.await
.unwrap();
let task = store.get(id).await.unwrap();
assert_eq!(
task.status,
TaskStatus::NeedsReview,
"stuck internal InReview task should be reset to NeedsReview"
);
}
#[tokio::test]
async fn recover_stuck_tasks_ages_in_review_from_status_entry_not_updated_at() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let mock = MockBackend::new();
let status_updates = Arc::clone(&mock.status_updates);
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let config = EngineConfig {
no_session_stuck_timeout: 600,
stuck_timeout: 1800,
..EngineConfig::default()
};
let id = store
.upsert_external(&crate::store::UpsertExternal {
repo: "owner/repo",
ext_id: "102",
title: "Fresh updated_at InReview",
body: "",
author: "",
url: "",
labels: &[],
origin: "github",
})
.await
.unwrap();
store.update_status(id, TaskStatus::InReview).await.unwrap();
set_in_review_entered_at_past(&store, id).await;
store
.set_fields(id, &[("pr_number", serde_json::json!(42))])
.await
.unwrap();
tick_recover_stuck_tasks(
&backend,
&tmux,
"owner/repo",
&task_manager,
&config,
&store,
&HashMap::new(),
&Arc::new(DashMap::new()),
)
.await
.unwrap();
let updates = status_updates.lock().unwrap();
assert_eq!(
updates.len(),
1,
"in_review stuck recovery must age from the status entry even when updated_at is fresh"
);
assert_eq!(updates[0].0, "102");
assert_eq!(updates[0].1, Status::NeedsReview);
}
async fn orphaned_review_status_after_recovery(with_decision: bool) -> TaskStatus {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let id = store
.upsert_external(&crate::store::UpsertExternal {
repo: "owner/repo",
ext_id: "55",
title: "Orphaned review",
body: "",
author: "",
url: "",
labels: &[],
origin: "github",
})
.await
.unwrap();
store.update_status(id, TaskStatus::InReview).await.unwrap();
set_in_review_entered_at_past(&store, id).await;
if with_decision {
store::store_log_activity(
&Some(Arc::clone(&store)),
"owner/repo",
"55",
"review_decision",
None,
None,
None::<&str>,
None::<&str>,
Some(&serde_json::json!({"decision": "approve"})),
)
.await;
}
tick_recover_stuck_tasks(
&backend,
&tmux,
"owner/repo",
&task_manager,
&EngineConfig::default(),
&store,
&HashMap::new(),
&Arc::new(DashMap::new()),
)
.await
.unwrap();
store.get(id).await.unwrap().status
}
#[tokio::test]
async fn stale_in_review_recovery_resets_orphaned_task_without_session() {
assert_eq!(
orphaned_review_status_after_recovery(false).await,
TaskStatus::NeedsReview
);
}
#[tokio::test]
async fn stale_in_review_recovery_leaves_decided_task_waiting_on_merge() {
assert_eq!(
orphaned_review_status_after_recovery(true).await,
TaskStatus::InReview
);
}
#[tokio::test]
async fn recover_stuck_tasks_resets_review_agent_failures() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let id = store
.upsert_external(&crate::store::UpsertExternal {
repo: "owner/repo",
ext_id: "104",
title: "Orphaned review with failures",
body: "",
author: "",
url: "",
labels: &[],
origin: "github",
})
.await
.unwrap();
store.update_status(id, TaskStatus::InReview).await.unwrap();
store
.set_fields(id, &[("review_agent_failures", serde_json::json!(2))])
.await
.unwrap();
set_in_review_entered_at_past(&store, id).await;
tick_recover_stuck_tasks(
&backend,
&tmux,
"owner/repo",
&task_manager,
&EngineConfig::default(),
&store,
&HashMap::new(),
&Arc::new(DashMap::new()),
)
.await
.unwrap();
let task = store.get(id).await.unwrap();
assert_eq!(task.status, TaskStatus::NeedsReview);
assert_eq!(
task.review_agent_failures, 0,
"a dead review session is an infrastructure event, not an agent failure"
);
}
#[tokio::test]
async fn recover_stuck_tasks_reclaims_hung_in_review_session() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let mock = MockBackend::new();
let status_updates = Arc::clone(&mock.status_updates);
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let config = EngineConfig {
no_session_stuck_timeout: 600,
stuck_timeout: 1800,
..EngineConfig::default()
};
let id = store
.upsert_external(&crate::store::UpsertExternal {
repo: "owner/repo",
ext_id: "103",
title: "Hung review session",
body: "",
author: "",
url: "",
labels: &[],
origin: "github",
})
.await
.unwrap();
store.update_status(id, TaskStatus::InReview).await.unwrap();
set_in_review_entered_at_past(&store, id).await;
store
.set_fields(id, &[("pr_number", serde_json::json!(43))])
.await
.unwrap();
let session_name = tmux.session_name("owner/repo", "103-review");
let mut session_map = HashMap::new();
session_map.insert(session_name, true);
tick_recover_stuck_tasks(
&backend,
&tmux,
"owner/repo",
&task_manager,
&config,
&store,
&session_map,
&Arc::new(DashMap::new()),
)
.await
.unwrap();
let updates = status_updates.lock().unwrap();
assert_eq!(
updates.len(),
1,
"a live hung review session must be reclaimed once the in_review entry ages past stuck_timeout"
);
assert_eq!(updates[0].0, "103");
assert_eq!(updates[0].1, Status::NeedsReview);
}
#[tokio::test]
async fn untracked_repo_recovery_resets_stale_in_progress_to_new() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let tmux = Arc::new(TmuxManager::new());
let config = EngineConfig {
no_session_stuck_timeout: 600,
stuck_timeout: 1800,
..EngineConfig::default()
};
let id = store
.upsert_external(&crate::store::UpsertExternal {
repo: "owner/other",
ext_id: "777",
title: "Cross-repo stuck task",
body: "",
author: "",
url: "",
labels: &[],
origin: "github",
})
.await
.unwrap();
store
.update_status(id, TaskStatus::InProgress)
.await
.unwrap();
set_task_updated_at_past(&store, id).await;
let active = HashSet::from(["owner/repo".to_string()]);
recover_untracked_repo_stuck_tasks(
&tmux,
&config,
&store,
&HashMap::new(),
&Arc::new(DashMap::new()),
&active,
)
.await;
let task = store.get(id).await.unwrap();
assert_eq!(
task.status,
TaskStatus::New,
"cross-repo stale in_progress task should be reset to New"
);
assert_eq!(task.route_attempts, 0, "route attempts should be cleared");
assert!(task.agent.is_none(), "agent should be cleared");
assert!(task.model.is_none(), "model should be cleared");
}
#[tokio::test]
async fn untracked_repo_recovery_leaves_active_repos_to_their_own_pass() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let tmux = Arc::new(TmuxManager::new());
let config = EngineConfig {
no_session_stuck_timeout: 600,
stuck_timeout: 1800,
..EngineConfig::default()
};
let id = store
.upsert_external(&crate::store::UpsertExternal {
repo: "owner/other",
ext_id: "778",
title: "Stuck task in an active repo",
body: "",
author: "",
url: "",
labels: &[],
origin: "github",
})
.await
.unwrap();
store
.update_status(id, TaskStatus::InProgress)
.await
.unwrap();
set_task_updated_at_past(&store, id).await;
let active = HashSet::from(["owner/repo".to_string(), "owner/other".to_string()]);
recover_untracked_repo_stuck_tasks(
&tmux,
&config,
&store,
&HashMap::new(),
&Arc::new(DashMap::new()),
&active,
)
.await;
assert_eq!(store.get(id).await.unwrap().status, TaskStatus::InProgress);
}
#[tokio::test]
async fn recover_stuck_tasks_finalizes_open_task_runs_rows() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let mock = MockBackend::new();
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let config = EngineConfig {
no_session_stuck_timeout: 600,
stuck_timeout: 1800,
..EngineConfig::default()
};
let id = store
.create_internal("owner/repo", "Stuck with open run", "", "cron", "1", None)
.await
.unwrap();
store
.set_fields(
id,
&[
("agent", serde_json::json!("claude")),
("model", serde_json::json!("sonnet")),
("route_attempts", serde_json::json!(1)),
],
)
.await
.unwrap();
store
.update_status(id, TaskStatus::InProgress)
.await
.unwrap();
set_task_updated_at_past(&store, id).await;
store
.start_run(&crate::store::StartRun {
task_id: id,
attempt: 1,
run_type: "agent",
agent: "claude",
model: "sonnet",
command: "claude --model sonnet",
prompt: "fix it",
})
.await
.unwrap();
tick_recover_stuck_tasks(
&backend,
&tmux,
"owner/repo",
&task_manager,
&config,
&store,
&HashMap::new(),
&Arc::new(DashMap::new()),
)
.await
.unwrap();
let task = store.get(id).await.unwrap();
assert_eq!(task.status, TaskStatus::New);
let runs = store.get_runs(id).await.unwrap();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].outcome, "aborted");
assert!(runs[0].completed_at.is_some());
assert_eq!(
runs[0].error,
"stuck-task recovery: internal no session found"
);
}
#[tokio::test]
async fn recover_stuck_tasks_skips_reclaim_while_dispatch_guard_held() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let mock = MockBackend::new();
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let config = EngineConfig {
no_session_stuck_timeout: 600,
stuck_timeout: 1800,
..EngineConfig::default()
};
let id = store
.create_internal(
"owner/repo",
"Long-running daily job",
"",
"cron",
"1",
None,
)
.await
.unwrap();
store
.set_fields(
id,
&[
("agent", serde_json::json!("claude")),
("model", serde_json::json!("sonnet")),
("route_attempts", serde_json::json!(1)),
],
)
.await
.unwrap();
store
.update_status(id, TaskStatus::InProgress)
.await
.unwrap();
set_task_updated_at_past(&store, id).await;
let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
dispatching.insert(
format!("owner/repo/internal:{id}"),
format!("internal:{id}"),
);
tick_recover_stuck_tasks(
&backend,
&tmux,
"owner/repo",
&task_manager,
&config,
&store,
&HashMap::new(),
&dispatching,
)
.await
.unwrap();
let task = store.get(id).await.unwrap();
assert_eq!(
task.status,
TaskStatus::InProgress,
"task should not be reclaimed while its dispatch guard is still held"
);
let runs = store.get_runs(id).await.unwrap();
assert!(runs.is_empty());
dispatching.clear();
tick_recover_stuck_tasks(
&backend,
&tmux,
"owner/repo",
&task_manager,
&config,
&store,
&HashMap::new(),
&dispatching,
)
.await
.unwrap();
let task = store.get(id).await.unwrap();
assert_eq!(
task.status,
TaskStatus::New,
"task should still be reclaimed once the dispatch guard is released"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn recover_stuck_tasks_never_reclaims_while_guard_held_under_real_concurrency() {
const HAMMER_ITERATIONS: u32 = 300;
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let mock = MockBackend::new();
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let config = EngineConfig {
no_session_stuck_timeout: 600,
stuck_timeout: 1800,
..EngineConfig::default()
};
let id = store
.create_internal(
"owner/repo",
"Long-running daily job",
"",
"cron",
"1",
None,
)
.await
.unwrap();
store
.set_fields(
id,
&[
("agent", serde_json::json!("claude")),
("model", serde_json::json!("sonnet")),
("route_attempts", serde_json::json!(1)),
],
)
.await
.unwrap();
store
.update_status(id, TaskStatus::InProgress)
.await
.unwrap();
set_task_updated_at_past(&store, id).await;
let dispatch_key = format!("owner/repo/internal:{id}");
let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
let claim_task_id = format!("internal:{id}");
{
match dispatching.entry(dispatch_key.clone()) {
Entry::Vacant(slot) => {
slot.insert(claim_task_id);
}
Entry::Occupied(_) => panic!("dispatch key already claimed"),
}
}
let guard = DispatchGuard::new(Arc::clone(&dispatching), dispatch_key.clone());
let (release_tx, release_rx) = oneshot::channel::<()>();
let holder = tokio::spawn(async move {
let _ = release_rx.await;
drop(guard);
});
for i in 0..HAMMER_ITERATIONS {
tick_recover_stuck_tasks(
&backend,
&tmux,
"owner/repo",
&task_manager,
&config,
&store,
&HashMap::new(),
&dispatching,
)
.await
.unwrap();
let task = store.get(id).await.unwrap();
assert_eq!(
task.status,
TaskStatus::InProgress,
"task must never be reclaimed while the dispatch guard is deterministically \
still held, even under real cross-thread concurrency (dispatch_key={dispatch_key}, \
iteration={i}, contains_key_now={}, map_len_now={})",
dispatching.contains_key(&dispatch_key),
dispatching.len(),
);
task::yield_now().await;
}
let _ = release_tx.send(());
holder.await.unwrap();
tick_recover_stuck_tasks(
&backend,
&tmux,
"owner/repo",
&task_manager,
&config,
&store,
&HashMap::new(),
&dispatching,
)
.await
.unwrap();
let task = store.get(id).await.unwrap();
assert_eq!(
task.status,
TaskStatus::New,
"task should be reclaimed once the dispatch guard is released"
);
}
#[tokio::test]
async fn recover_stuck_tasks_skips_external_in_review_waiting_for_human_review() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let mock = MockBackend::new();
let status_updates = Arc::clone(&mock.status_updates);
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let config = EngineConfig {
no_session_stuck_timeout: 600,
stuck_timeout: 1800,
..EngineConfig::default()
};
let id = store
.upsert_external(&crate::store::UpsertExternal {
repo: "owner/repo",
ext_id: "101",
title: "Waiting For Human Review",
body: "",
author: "",
url: "",
labels: &[],
origin: "github",
})
.await
.unwrap();
store.update_status(id, TaskStatus::InReview).await.unwrap();
set_task_updated_at_past(&store, id).await;
tick_recover_stuck_tasks(
&backend,
&tmux,
"owner/repo",
&task_manager,
&config,
&store,
&HashMap::new(),
&Arc::new(DashMap::new()),
)
.await
.unwrap();
let updates = status_updates.lock().unwrap();
assert!(
updates.is_empty(),
"InReview task waiting for human review should not be reset"
);
}
#[tokio::test]
async fn unblock_parents_no_blocked_tasks() {
let mock = MockBackend::new(); let status_updates = Arc::clone(&mock.status_updates);
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = make_task_manager(Arc::clone(&backend));
let store = Arc::new(TaskStore::open_memory().await.unwrap());
tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
.await
.unwrap();
assert!(status_updates.lock().unwrap().is_empty());
}
#[tokio::test]
async fn unblock_parents_handles_multiple_blocked_tasks() {
let mut mock = MockBackend::new();
let t40 = make_task("40", &["status:blocked"]);
mock.blocked_tasks.push(t40);
mock.sub_issues
.insert("40".to_string(), vec![ExternalId("41".to_string())]);
mock.tasks_by_id
.insert("41".to_string(), make_task("41", &["status:done"]));
let t50 = make_task("50", &["status:blocked"]);
mock.blocked_tasks.push(t50);
mock.sub_issues
.insert("50".to_string(), vec![ExternalId("51".to_string())]);
mock.tasks_by_id
.insert("51".to_string(), make_task("51", &["status:in_progress"]));
let status_updates = Arc::clone(&mock.status_updates);
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = make_task_manager(Arc::clone(&backend));
let store = Arc::new(TaskStore::open_memory().await.unwrap());
tick_unblock_parents(&backend, &task_manager, &store, "test/repo")
.await
.unwrap();
let updates = status_updates.lock().unwrap();
assert_eq!(updates.len(), 1, "only one parent should be unblocked");
assert_eq!(updates[0].0, "40", "task 40 should be unblocked");
assert_eq!(updates[0].1, Status::New);
}
#[tokio::test]
async fn tick_all_is_a_no_op_without_projects() {
let transport = Arc::new(Transport::new());
let capture = Arc::new(CaptureService::new(Arc::clone(&transport)));
let tmux = Arc::new(TmuxManager::new());
let semaphore = Arc::new(Semaphore::new(1));
let router = Arc::new(RwLock::new(cooled_review_router_for_test()));
let (weight_tx, mut weight_rx) = mpsc::channel(8);
let dispatching = Arc::new(DashMap::new());
let auto_merge_in_flight = Arc::new(DashSet::new());
tick_all(
&[],
&tmux,
&capture,
&semaphore,
&EngineConfig::default(),
&router,
&weight_tx,
&dispatching,
Some(&transport),
&HashSet::new(),
&auto_merge_in_flight,
)
.await;
assert!(dispatching.is_empty(), "no project, so no dispatch claimed");
assert!(
weight_rx.try_recv().is_err(),
"no project, so no weight signal"
);
}
#[serial_test::serial(cooldown_state)]
#[tokio::test]
async fn tick_all_runs_the_global_needs_review_sweep_once_for_two_projects() {
cooldown::reset_global_state().await;
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let id = store
.upsert_external(&UpsertExternal {
repo: "owner/one",
ext_id: "7",
title: "Stale needs_review task",
body: "",
author: "user",
url: "https://github.com/owner/one/issues/7",
labels: &[],
origin: "github",
})
.await
.unwrap();
store
.update_status(id, TaskStatus::NeedsReview)
.await
.unwrap();
sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
.bind(id)
.execute(store.pool())
.await
.unwrap();
let engines: Vec<ProjectEngine> = ["owner/one", "owner/two"]
.iter()
.map(|repo| {
let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
ProjectEngine {
repo: repo.to_string(),
project_dir: PathBuf::from("/nonexistent"),
backend: Arc::clone(&backend),
task_manager: Arc::new(TaskManager::with_store(
backend,
Arc::clone(&store),
repo.to_string(),
)),
runner: Arc::new(TaskRunner::new(repo.to_string())),
store: Arc::clone(&store),
}
})
.collect();
let mut config = RouterConfig::default();
config
.model_map
.entry("review".to_string())
.or_default()
.insert("claude".to_string(), vec!["sonnet".to_string()]);
let router = Arc::new(RwLock::new(Router::new_for_test(
config,
vec!["claude".to_string()],
)));
let transport = Arc::new(Transport::new());
let capture = Arc::new(CaptureService::new(Arc::clone(&transport)));
let (weight_tx, _weight_rx) = mpsc::channel(8);
tick_all(
&engines,
&Arc::new(TmuxManager::new()),
&capture,
&Arc::new(Semaphore::new(1)),
&EngineConfig::default(),
&router,
&weight_tx,
&Arc::new(DashMap::new()),
Some(&transport),
&engines.iter().map(|e| e.repo.clone()).collect(),
&Arc::new(DashSet::new()),
)
.await;
assert_eq!(
store.get(id).await.unwrap().needs_review_refires,
1,
"two projects must share one pass of the global NeedsReview sweep"
);
}
#[tokio::test]
async fn tick_all_does_not_kill_sessions_it_did_not_create() {
let tmux = Arc::new(TmuxManager::new());
let Ok(session_name) = tmux
.create_session("other/foreign", "3889", "/tmp", "sleep 30", &[])
.await
else {
eprintln!("Skipping test: tmux not available or failed to create test session");
return;
};
let _guard = SessionGuard(session_name.clone());
time::sleep(Duration::from_millis(1100)).await;
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
let engines = vec![ProjectEngine {
repo: "owner/one".to_string(),
project_dir: PathBuf::from("/nonexistent"),
backend: Arc::clone(&backend),
task_manager: Arc::new(TaskManager::with_store(
backend,
Arc::clone(&store),
"owner/one".to_string(),
)),
runner: Arc::new(TaskRunner::new("owner/one".to_string())),
store,
}];
let transport = Arc::new(Transport::new());
let capture = Arc::new(CaptureService::new(Arc::clone(&transport)));
let (weight_tx, _weight_rx) = mpsc::channel(8);
let router = Arc::new(RwLock::new(cooled_review_router_for_test()));
tick_all(
&engines,
&tmux,
&capture,
&Arc::new(Semaphore::new(1)),
&EngineConfig::default(),
&router,
&weight_tx,
&Arc::new(DashMap::new()),
Some(&transport),
&engines.iter().map(|e| e.repo.clone()).collect(),
&Arc::new(DashSet::new()),
)
.await;
assert!(
tmux.session_exists(&session_name).await,
"tick_all must not kill a tmux session it did not start"
);
}
#[tokio::test]
async fn detect_silent_agents_skips_backend_for_internal_tasks() {
let transport = Arc::new(Transport::new());
let capture = Arc::new(CaptureService::new(transport));
let mock = Arc::new(MockBackend::new());
let backend: Arc<dyn ExternalBackend> = Arc::<MockBackend>::clone(&mock);
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let config = EngineConfig {
silence_grace_period: 0,
..EngineConfig::default()
};
let internal_id = store
.create_internal("owner/repo", "Silent internal", "", "cron", "1", None)
.await
.unwrap();
store
.update_status(internal_id, TaskStatus::InProgress)
.await
.unwrap();
let task_id = format!("internal:{internal_id}");
capture
.register_session("owner/repo", &task_id, "orch-test-internal")
.await;
time::sleep(Duration::from_millis(1100)).await;
tick_detect_silent_agents(
&tmux,
"owner/repo",
&capture,
&backend,
&task_manager,
&config,
&store,
)
.await
.unwrap();
assert!(
mock.removed_labels.lock().unwrap().is_empty(),
"internal tasks should skip backend label removal"
);
assert!(
mock.posted_comments.lock().unwrap().is_empty(),
"internal tasks should skip backend comments"
);
let task = store.get(internal_id).await.unwrap();
assert!(
matches!(task.status, TaskStatus::Routed | TaskStatus::New),
"internal task should be routed to fallback or reset to new, got {:?}",
task.status
);
}
#[tokio::test]
async fn detect_silent_agents_finalizes_open_task_runs_rows() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let mock = MockBackend::new();
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let transport = Arc::new(Transport::new());
let capture = Arc::new(CaptureService::new(transport));
let tmux = Arc::new(TmuxManager::new());
let config = EngineConfig {
silence_grace_period: 60,
silence_cooldown: 300,
..EngineConfig::default()
};
let internal_id = store
.create_internal("owner/repo", "Silent with open run", "", "cron", "1", None)
.await
.unwrap();
store
.set_fields(
internal_id,
&[
("agent", serde_json::json!("claude")),
("model", serde_json::json!("sonnet")),
],
)
.await
.unwrap();
store
.update_status(internal_id, TaskStatus::InProgress)
.await
.unwrap();
let task_id = format!("internal:{internal_id}");
capture
.register_session("owner/repo", &task_id, "orch-test-silent-run")
.await;
capture
.set_buffer_state_for_test(
"owner/repo",
&task_id,
false,
false,
chrono::Utc::now() - chrono::Duration::seconds(120),
)
.await;
store
.start_run(&crate::store::StartRun {
task_id: internal_id,
attempt: 1,
run_type: "agent",
agent: "claude",
model: "sonnet",
command: "claude --model sonnet",
prompt: "fix it",
})
.await
.unwrap();
tick_detect_silent_agents(
&tmux,
"owner/repo",
&capture,
&backend,
&task_manager,
&config,
&store,
)
.await
.unwrap();
let runs = store.get_runs(internal_id).await.unwrap();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].outcome, "aborted");
assert!(runs[0].completed_at.is_some());
assert_eq!(runs[0].error, "silence detection: session killed");
}
#[tokio::test]
async fn phase1_session_completion_touch_prevents_phase2_reclaim() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let mock = MockBackend::new();
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let config = EngineConfig {
no_session_stuck_timeout: 600,
stuck_timeout: 1800,
..EngineConfig::default()
};
let id = store
.create_internal("owner/repo", "Completed task", "", "cron", "1", None)
.await
.unwrap();
store
.update_status(id, TaskStatus::InProgress)
.await
.unwrap();
set_task_updated_at_past(&store, id).await;
let task_id = format!("internal:{id}");
store_touch_updated_at(&Some(Arc::clone(&store)), "owner/repo", &task_id).await;
tick_recover_stuck_tasks(
&backend,
&tmux,
"owner/repo",
&task_manager,
&config,
&store,
&HashMap::new(),
&Arc::new(DashMap::new()),
)
.await
.unwrap();
let task = store.get(id).await.unwrap();
assert_eq!(
task.status,
TaskStatus::InProgress,
"Phase 2 must not reclaim a task whose updated_at was just touched by Phase 1"
);
}
#[tokio::test]
async fn stuck_task_timing_treats_dead_session_as_missing() {
let tmux = Arc::new(TmuxManager::new());
let repo = "owner/repo";
let task_id = "internal:33498";
let session_name = tmux.session_name(repo, task_id);
let create_result = tmux
.command()
.args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
.output()
.await;
match create_result {
Ok(o) if o.status.success() => {}
_ => {
eprintln!("Skipping test: tmux not available or failed to create test session");
return;
}
}
let _guard = SessionGuard(session_name.clone());
let set_option_result = tmux
.command()
.args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
.output()
.await;
if !matches!(set_option_result, Ok(ref o) if o.status.success()) {
eprintln!("Skipping test: unable to set tmux remain-on-exit option");
return;
}
let send_exit_result = tmux
.command()
.args(["send-keys", "-t", &session_name, "exit", "Enter"])
.output()
.await;
if !matches!(send_exit_result, Ok(ref o) if o.status.success()) {
eprintln!("Skipping test: unable to exit tmux pane");
return;
}
for _ in 0..20 {
time::sleep(Duration::from_millis(100)).await;
if !tmux.session_is_running(&session_name).await {
break;
}
}
let mut session_map = tmux.batch_session_active().await;
for _ in 0..20 {
if !session_map.get(&session_name).copied().unwrap_or(false) {
break;
}
time::sleep(Duration::from_millis(100)).await;
session_map = tmux.batch_session_active().await;
}
if session_map.get(&session_name).copied().unwrap_or(false) {
eprintln!(
"Skipping test: session still alive in batch_session_active after retries (CI tmux lag)"
);
return;
}
let config = EngineConfig {
no_session_stuck_timeout: 600,
stuck_timeout: 1800,
..EngineConfig::default()
};
let updated_at = (chrono::Utc::now()
- chrono::Duration::seconds(
i64::try_from(config.no_session_stuck_timeout).expect("timeout fits in i64") + 5,
))
.to_rfc3339();
let timing = stuck_task_timing_from_map(
&tmux,
repo,
task_id,
&updated_at,
&config,
"parse failure",
&session_map,
)
.expect("dead session should be treated as missing");
assert!(!timing.has_session);
assert_eq!(timing.threshold, config.no_session_stuck_timeout);
}
#[tokio::test]
#[serial_test::serial(suspend_state)]
async fn stuck_task_timing_discounts_host_suspend_gap() {
super::super::suspend::clear_for_test();
let tmux = Arc::new(TmuxManager::new());
let repo = "owner/repo";
let task_id = "internal:99001";
let session_map = HashMap::new();
let config = EngineConfig {
no_session_stuck_timeout: 1800,
stuck_timeout: 1800,
..EngineConfig::default()
};
let updated_at = (chrono::Utc::now() - chrono::Duration::minutes(33)).to_rfc3339();
let suspend_gap_detected_at = chrono::Utc::now() - chrono::Duration::minutes(1);
super::super::suspend::inject_gap_for_test(
suspend_gap_detected_at,
chrono::Duration::minutes(30),
);
let timing = stuck_task_timing_from_map(
&tmux,
repo,
task_id,
&updated_at,
&config,
"parse failure",
&session_map,
);
assert!(
timing.is_none(),
"task age should be discounted below the stuck threshold once suspend time is subtracted, got {timing:?}"
);
super::super::suspend::clear_for_test();
}
#[tokio::test]
async fn recover_stuck_tasks_reclaims_routed_task_blocked_by_stale_session() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let mock = MockBackend::new();
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let config = EngineConfig {
no_session_stuck_timeout: 600,
stuck_timeout: 1,
..EngineConfig::default()
};
let id = store
.create_internal("owner/repo", "Blocked routed task", "", "cron", "1", None)
.await
.unwrap();
store.update_status(id, TaskStatus::Routed).await.unwrap();
set_task_updated_at_past(&store, id).await;
let task_id = format!("internal:{id}");
let session_name = tmux.session_name("owner/repo", &task_id);
let create_result = tmux
.command()
.args([
"new-session",
"-d",
"-s",
&session_name,
"-c",
"/tmp",
"sleep 60",
])
.output()
.await;
match create_result {
Ok(o) if o.status.success() => {}
_ => {
eprintln!("Skipping test: tmux not available or failed to create test session");
return;
}
}
let session_map = tmux.batch_session_active().await;
assert_eq!(session_map.get(&session_name).copied(), Some(true));
tick_recover_stuck_tasks(
&backend,
&tmux,
"owner/repo",
&task_manager,
&config,
&store,
&session_map,
&Arc::new(DashMap::new()),
)
.await
.unwrap();
let task = store.get(id).await.unwrap();
assert_eq!(
task.status,
TaskStatus::New,
"routed task blocked by a stale session should be reset for re-routing"
);
assert!(
!tmux.session_exists(&session_name).await,
"recovery should kill the stale tmux session so dispatch can proceed"
);
}
async fn live_complex_run(
store: &TaskStore,
tmux: &TmuxManager,
minutes: i64,
updated_minutes_ago: i64,
) -> Option<(i64, String)> {
let id = store
.create_internal("owner/repo", "Long complex run", "", "cron", "1", None)
.await
.unwrap();
store
.set_fields(id, &[("complexity", serde_json::json!("complex"))])
.await
.unwrap();
store
.update_status(id, TaskStatus::InProgress)
.await
.unwrap();
let ago = |m: i64| {
(chrono::Utc::now() - chrono::Duration::minutes(m))
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string()
};
let entered = ago(minutes);
sqlx::query(
"UPDATE task_activity SET timestamp = ? WHERE task_id = ? AND to_status = 'in_progress'",
)
.bind(&entered)
.bind(id)
.execute(store.pool())
.await
.unwrap();
sqlx::query("UPDATE tasks SET updated_at = ? WHERE id = ?")
.bind(ago(updated_minutes_ago))
.bind(id)
.execute(store.pool())
.await
.unwrap();
let session_name = tmux.session_name("owner/repo", &format!("internal:{id}"));
let created = tmux
.command()
.args([
"new-session",
"-d",
"-s",
&session_name,
"-c",
"/tmp",
"sleep 60",
])
.output()
.await;
match created {
Ok(o) if o.status.success() => Some((id, session_name)),
_ => None,
}
}
fn long_run_config() -> EngineConfig {
EngineConfig {
stuck_timeout: 1800,
run_timeouts: RunTimeouts {
base: 1800,
medium: 2700,
complex: 3600,
},
..EngineConfig::default()
}
}
#[test]
fn tick_router_loads_the_config_once_for_many_live_runs() {
let cell = OnceLock::new();
let loads = Cell::new(0);
for _ in 0..4 {
tick_router(&cell, || {
loads.set(loads.get() + 1);
RouterConfig::default()
});
}
assert_eq!(loads.get(), 1);
}
#[tokio::test]
async fn run_tier_follows_the_tier_of_the_stored_model() {
let mut router = RouterConfig::default();
router.model_map.clear();
for (tier, model) in [("simple", "rt-s"), ("complex", "rt-c")] {
router
.model_map
.entry(tier.to_string())
.or_default()
.insert("rtagent".to_string(), vec![model.to_string()]);
}
let store = TaskStore::open_memory().await.unwrap();
let id = store
.create_internal(
"owner/repo",
"Simple on a complex model",
"",
"cron",
"1",
None,
)
.await
.unwrap();
store
.set_fields(
id,
&[
("complexity", serde_json::json!("simple")),
("agent", serde_json::json!("rtagent")),
("model", serde_json::json!("rt-c")),
],
)
.await
.unwrap();
let task = store.get(id).await.unwrap();
assert_eq!(run_tier(&router, &task), "complex");
store
.set_fields(id, &[("model", serde_json::json!("rt-s"))])
.await
.unwrap();
let task = store.get(id).await.unwrap();
assert_eq!(run_tier(&router, &task), "simple");
}
#[tokio::test]
async fn recover_stuck_tasks_keeps_a_live_complex_run_inside_its_timeout() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let Some((id, session_name)) = live_complex_run(&store, &tmux, 31, 31).await else {
eprintln!("Skipping test: tmux not available or failed to create test session");
return;
};
let session_map = tmux.batch_session_active().await;
tick_recover_stuck_tasks(
&backend,
&tmux,
"owner/repo",
&task_manager,
&long_run_config(),
&store,
&session_map,
&Arc::new(DashMap::new()),
)
.await
.unwrap();
let alive = tmux.session_exists(&session_name).await;
let _ = tmux.kill_session(&session_name).await;
assert_eq!(
store.get(id).await.unwrap().status,
TaskStatus::InProgress,
"a complex run at 31 minutes is inside its 3600s timeout"
);
assert!(alive, "recovery must not touch a run inside its timeout");
}
#[tokio::test]
async fn recover_stuck_tasks_kills_a_live_complex_run_past_its_timeout() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let Some((id, session_name)) = live_complex_run(&store, &tmux, 66, 0).await else {
eprintln!("Skipping test: tmux not available or failed to create test session");
return;
};
let session_map = tmux.batch_session_active().await;
tick_recover_stuck_tasks(
&backend,
&tmux,
"owner/repo",
&task_manager,
&long_run_config(),
&store,
&session_map,
&Arc::new(DashMap::new()),
)
.await
.unwrap();
let alive = tmux.session_exists(&session_name).await;
let _ = tmux.kill_session(&session_name).await;
assert_eq!(
store.get(id).await.unwrap().status,
TaskStatus::New,
"a complex run past its timeout plus grace is stuck"
);
assert!(
!alive,
"recovery must kill the session it reports as killed"
);
}
#[tokio::test]
async fn dispatch_does_not_deadlock_under_write_lock() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let mock = MockBackend::new();
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let semaphore = Arc::new(Semaphore::new(4));
let (weight_tx, _weight_rx) = mpsc::channel(16);
let dispatching = Arc::new(DashMap::new());
let transport = Arc::new(Transport::new());
let capture = Arc::new(CaptureService::new(transport));
let router_config = RouterConfig::default();
let router = Router::new_for_test(router_config, vec!["claude".to_string()]);
let router_arc = Arc::new(RwLock::new(router));
let write_guard = router_arc.write().await;
let dispatch_mode = dispatch_mode_from_router(&write_guard);
let result = time::timeout(
Duration::from_secs(2),
tick_dispatch_tasks(
&backend,
&tmux,
"owner/repo",
&Arc::new(TaskRunner::new("owner/repo".to_string())),
&capture,
&semaphore,
&task_manager,
&weight_tx,
dispatch_mode,
&dispatching,
&store,
&HashMap::new(),
),
)
.await;
assert!(
result.is_ok(),
"tick_dispatch_tasks deadlocked! It tried to acquire a read lock \
on router_arc while a write lock was already held (issue #1361)"
);
}
#[tokio::test]
async fn dispatch_skips_task_whose_routed_model_entered_cooldown() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let mock = MockBackend::new();
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
let semaphore = Arc::new(Semaphore::new(4));
let (weight_tx, _weight_rx) = mpsc::channel(16);
let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
let transport = Arc::new(Transport::new());
let capture = Arc::new(CaptureService::new(transport));
let id = store
.upsert_external(&UpsertExternal {
repo: "owner/repo",
ext_id: "999",
title: "Stale routed task",
body: "",
author: "user",
url: "https://github.com/owner/repo/issues/999",
labels: &[],
origin: "github",
})
.await
.unwrap();
store
.store_route(&StoreRoute {
id,
agent: "minimax",
model: Some("opus"),
complexity: "medium",
estimate: 0,
reason: "test",
profile: "{}",
skills: "[]",
})
.await
.unwrap();
store.update_status(id, DbStatus::Routed).await.unwrap();
cooldown::set_model_cooldown("minimax", "opus", 300, "rate_limit_usage_window").await;
let dispatch_mode = DispatchMode {
is_degraded: false,
healthy_agents: 1,
threshold: 1,
};
tick_dispatch_tasks(
&backend,
&tmux,
"owner/repo",
&runner,
&capture,
&semaphore,
&task_manager,
&weight_tx,
dispatch_mode,
&dispatching,
&store,
&HashMap::new(),
)
.await
.unwrap();
let task = store.get(id).await.unwrap();
assert_eq!(
task.status,
DbStatus::Routed,
"task whose routed model is now cooled down must stay Routed, not be dispatched"
);
assert!(
dispatching.is_empty(),
"dispatch guard must be released when skipping a stale-cooldown task"
);
}
#[tokio::test]
async fn dispatch_skips_a_task_with_an_open_dependency() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let mock = MockBackend::new();
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let runner = Arc::new(TaskRunner::new("owner/repo".to_string()));
let semaphore = Arc::new(Semaphore::new(4));
let (weight_tx, _weight_rx) = mpsc::channel(16);
let dispatching: Arc<DashMap<String, String>> = Arc::new(DashMap::new());
let transport = Arc::new(Transport::new());
let capture = Arc::new(CaptureService::new(transport));
let id = store
.upsert_external(&UpsertExternal {
repo: "owner/repo",
ext_id: "42",
title: "Waiting routed task",
body: "",
author: "user",
url: "https://github.com/owner/repo/issues/42",
labels: &[],
origin: "github",
})
.await
.unwrap();
store
.replace_dependencies(
id,
&[NewDependency {
repo: "owner/repo".into(),
number: 7,
source: DepSource::Github,
}],
)
.await
.unwrap();
store
.store_route(&StoreRoute {
id,
agent: "claude",
model: Some("haiku"),
complexity: "simple",
estimate: 0,
reason: "test",
profile: "{}",
skills: "[]",
})
.await
.unwrap();
store.update_status(id, DbStatus::Routed).await.unwrap();
let dispatch_mode = DispatchMode {
is_degraded: false,
healthy_agents: 1,
threshold: 1,
};
tick_dispatch_tasks(
&backend,
&tmux,
"owner/repo",
&runner,
&capture,
&semaphore,
&task_manager,
&weight_tx,
dispatch_mode,
&dispatching,
&store,
&HashMap::new(),
)
.await
.unwrap();
let task = store.get(id).await.unwrap();
assert_eq!(task.status, DbStatus::Waiting);
assert!(dispatching.is_empty());
}
#[tokio::test]
async fn completed_session_does_not_cause_stuck_reclaim_race() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let mock = MockBackend::new();
let backend: Arc<dyn ExternalBackend> = Arc::new(mock);
let task_manager = Arc::new(TaskManager::with_store(
Arc::clone(&backend),
Arc::clone(&store),
"owner/repo".to_string(),
));
let tmux = Arc::new(TmuxManager::new());
let transport = Arc::new(Transport::new());
let capture = Arc::new(CaptureService::new(transport));
let id = store
.upsert_external(&crate::store::UpsertExternal {
repo: "owner/repo",
ext_id: "200",
title: "Race Task",
body: "",
author: "",
url: "",
labels: &[],
origin: "github",
})
.await
.unwrap();
store
.update_status(id, TaskStatus::InProgress)
.await
.unwrap();
sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
.bind(id)
.execute(store.pool())
.await
.unwrap();
let task_id = "200";
let session_name = tmux.session_name("owner/repo", task_id);
let create_result = tmux
.command()
.args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
.output()
.await;
match create_result {
Ok(o) if o.status.success() => {}
_ => {
eprintln!("Skipping test: tmux not available or failed to create test session");
return;
}
}
let _guard = SessionGuard(session_name.clone());
let _ = tmux
.command()
.args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
.output()
.await;
let _ = tmux
.command()
.args(["send-keys", "-t", &session_name, "exit", "Enter"])
.output()
.await;
for _ in 0..20 {
time::sleep(Duration::from_millis(100)).await;
if !tmux.session_is_running(&session_name).await {
break;
}
}
let task_key = task_id.to_string();
capture
.register_session("owner/repo", &task_key, &session_name)
.await;
let config = EngineConfig {
no_session_stuck_timeout: 600,
stuck_timeout: 1800,
..EngineConfig::default()
};
tick_check_session_completions(&tmux, "owner/repo", &capture, &store)
.await
.unwrap();
{
let task_state = store.get(id).await.unwrap();
if task_state.updated_at.starts_with("2020") {
eprintln!(
"Skipping test: tick_check_session_completions did not update stored \
updated_at — session was not in snapshot (CI tmux environment issue)"
);
return;
}
}
tick_recover_stuck_tasks(
&backend,
&tmux,
"owner/repo",
&task_manager,
&config,
&store,
&HashMap::new(),
&Arc::new(DashMap::new()),
)
.await
.unwrap();
let task = store.get(id).await.unwrap();
assert_eq!(
task.status,
TaskStatus::InProgress,
"task should not be reclaimed to New"
);
}
#[tokio::test]
async fn session_completion_matches_repo_configured_with_git_suffix() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let tmux = Arc::new(TmuxManager::new());
let transport = Arc::new(Transport::new());
let capture = Arc::new(CaptureService::new(transport));
let repo = "owner/repo.git";
let id = store
.upsert_external(&crate::store::UpsertExternal {
repo,
ext_id: "201",
title: "Git-suffix repo task",
body: "",
author: "",
url: "",
labels: &[],
origin: "github",
})
.await
.unwrap();
store
.update_status(id, TaskStatus::InProgress)
.await
.unwrap();
sqlx::query("UPDATE tasks SET updated_at = '2020-01-01T00:00:00Z' WHERE id = ?")
.bind(id)
.execute(store.pool())
.await
.unwrap();
let task_id = "201";
let session_name = tmux.session_name(repo, task_id);
let create_result = tmux
.command()
.args(["new-session", "-d", "-s", &session_name, "-c", "/tmp"])
.output()
.await;
match create_result {
Ok(o) if o.status.success() => {}
_ => {
eprintln!("Skipping test: tmux not available or failed to create test session");
return;
}
}
let _guard = SessionGuard(session_name.clone());
let _ = tmux
.command()
.args(["set-option", "-t", &session_name, "remain-on-exit", "on"])
.output()
.await;
let _ = tmux
.command()
.args(["send-keys", "-t", &session_name, "exit", "Enter"])
.output()
.await;
for _ in 0..20 {
time::sleep(Duration::from_millis(100)).await;
if !tmux.session_is_running(&session_name).await {
break;
}
}
capture.register_session(repo, task_id, &session_name).await;
tick_check_session_completions(&tmux, repo, &capture, &store)
.await
.unwrap();
let task_state = store.get(id).await.unwrap();
if task_state.updated_at.starts_with("2020") {
eprintln!(
"Skipping test: tick_check_session_completions did not update stored \
updated_at — session was not in snapshot (CI tmux environment issue)"
);
return;
}
assert!(
!task_state.updated_at.starts_with("2020"),
"tick_check_session_completions must match its own repo's live sessions \
even when the repo is configured with a \".git\" suffix"
);
}
#[tokio::test]
async fn job_scheduler_returns_ok_on_duplicate_job_config() {
let tmp = tempfile::tempdir().unwrap();
let cfg = tmp.path().join(".orch.yml");
fs::write(
&cfg,
"jobs:\n - id: trading-scan\n schedule: \"0 9 * * *\"\n task:\n title: Scan\n body: do it\n",
)
.unwrap();
let jobs_dir = tmp.path().join("prompts").join("jobs");
fs::create_dir_all(&jobs_dir).unwrap();
fs::write(
jobs_dir.join("trading-scan.md"),
"---\nid: trading-scan\nschedule: '0 9 * * *'\ntitle: Trading Scan\n---\n\nDo trading scan.\n",
)
.unwrap();
let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
let result = tick_job_scheduler(&cfg, &backend, None, "test/repo", None).await;
assert!(
result.is_ok(),
"duplicate job id must not propagate as Err: {result:?}"
);
}
#[tokio::test]
async fn job_scheduler_processes_valid_project_when_other_has_duplicate() {
let bad_tmp = tempfile::tempdir().unwrap();
let bad_cfg = bad_tmp.path().join(".orch.yml");
fs::write(
&bad_cfg,
"jobs:\n - id: dup\n schedule: \"0 9 * * *\"\n task:\n title: Dup\n body: x\n",
)
.unwrap();
let bad_jobs_dir = bad_tmp.path().join("prompts").join("jobs");
fs::create_dir_all(&bad_jobs_dir).unwrap();
fs::write(
bad_jobs_dir.join("dup.md"),
"---\nid: dup\nschedule: '0 9 * * *'\ntitle: Dup\n---\n\nbody\n",
)
.unwrap();
let good_tmp = tempfile::tempdir().unwrap();
let good_cfg = good_tmp.path().join(".orch.yml");
fs::write(
&good_cfg,
"jobs:\n - id: valid-job\n schedule: \"0 9 * * *\"\n task:\n title: Valid\n body: ok\n",
)
.unwrap();
let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new());
let bad_result = tick_job_scheduler(&bad_cfg, &backend, None, "test/bad-repo", None).await;
assert!(
bad_result.is_ok(),
"bad project must not abort scheduler: {bad_result:?}"
);
let good_result =
tick_job_scheduler(&good_cfg, &backend, None, "test/good-repo", None).await;
assert!(
good_result.is_ok(),
"valid project must still run after bad project: {good_result:?}"
);
}
#[serial_test::serial(cooldown_state)]
#[tokio::test]
async fn global_sweep_routes_inactive_repo_tasks() {
cooldown::reset_global_state().await;
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let active_id = store
.upsert_external(&UpsertExternal {
repo: "active/repo",
ext_id: "1",
title: "Active task",
body: "",
author: "user",
url: "https://github.com/active/repo/issues/1",
labels: &[],
origin: "github",
})
.await
.unwrap();
let inactive_id = store
.upsert_external(&UpsertExternal {
repo: "inactive/repo",
ext_id: "42",
title: "Orphaned task",
body: "",
author: "user",
url: "https://github.com/inactive/repo/issues/42",
labels: &[],
origin: "github",
})
.await
.unwrap();
assert_eq!(store.get(active_id).await.unwrap().status, TaskStatus::New);
assert_eq!(
store.get(inactive_id).await.unwrap().status,
TaskStatus::New
);
let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
let mut tier = HashMap::new();
tier.insert("claude".to_string(), vec!["haiku".to_string()]);
model_map.insert("medium".to_string(), tier);
let config = RouterConfig {
mode: "round_robin".to_string(),
agents: vec!["claude".to_string()],
model_map,
..Default::default()
};
let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
let mut routing_quota = RoutingTickQuota::new(1);
route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
.await
.unwrap();
assert_eq!(
store.get(active_id).await.unwrap().status,
TaskStatus::New,
"global sweep must not touch tasks for the active repo"
);
let after = store.get(inactive_id).await.unwrap();
assert_eq!(
after.status,
TaskStatus::Routed,
"global sweep must route tasks from inactive repos"
);
assert!(
matches!(&after.agent, Some(a) if !a.is_empty()),
"agent must be set after routing"
);
}
#[serial_test::serial(cooldown_state)]
#[tokio::test]
async fn global_sweep_does_not_touch_other_active_repo_tasks() {
cooldown::reset_global_state().await;
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let other_active_id = store
.upsert_external(&UpsertExternal {
repo: "other-active/repo",
ext_id: "7",
title: "Other active project's task",
body: "",
author: "user",
url: "https://github.com/other-active/repo/issues/7",
labels: &[],
origin: "github",
})
.await
.unwrap();
let inactive_id = store
.upsert_external(&UpsertExternal {
repo: "inactive/repo",
ext_id: "42",
title: "Orphaned task",
body: "",
author: "user",
url: "https://github.com/inactive/repo/issues/42",
labels: &[],
origin: "github",
})
.await
.unwrap();
let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
let mut tier = HashMap::new();
tier.insert("claude".to_string(), vec!["haiku".to_string()]);
model_map.insert("medium".to_string(), tier);
let config = RouterConfig {
mode: "round_robin".to_string(),
agents: vec!["claude".to_string()],
model_map,
..Default::default()
};
let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
let active_repos: HashSet<String> =
["active/repo".to_string(), "other-active/repo".to_string()]
.into_iter()
.collect();
let mut routing_quota = RoutingTickQuota::new(1);
route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
.await
.unwrap();
assert_eq!(
store.get(other_active_id).await.unwrap().status,
TaskStatus::New,
"global sweep run from a different active repo's tick must not route \
another active repo's new tasks — that repo's own tick will route them"
);
assert_eq!(
store.get(inactive_id).await.unwrap().status,
TaskStatus::Routed,
"global sweep must still route tasks from genuinely orphaned repos"
);
}
#[serial_test::serial(cooldown_state)]
#[tokio::test]
async fn global_sweep_skips_no_agent_tasks() {
cooldown::reset_global_state().await;
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let id = store
.upsert_external(&UpsertExternal {
repo: "inactive/repo",
ext_id: "99",
title: "No-agent task",
body: "",
author: "user",
url: "https://github.com/inactive/repo/issues/99",
labels: &["no-agent".to_string()],
origin: "github",
})
.await
.unwrap();
let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
let mut tier = HashMap::new();
tier.insert("claude".to_string(), vec!["haiku".to_string()]);
model_map.insert("medium".to_string(), tier);
let config = RouterConfig {
mode: "round_robin".to_string(),
agents: vec!["claude".to_string()],
model_map,
..Default::default()
};
let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
let mut routing_quota = RoutingTickQuota::new(1);
route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
.await
.unwrap();
assert_eq!(
store.get(id).await.unwrap().status,
TaskStatus::New,
"no-agent tasks must not be routed"
);
}
#[serial_test::serial(cooldown_state)]
#[tokio::test]
async fn global_sweep_honors_shared_routing_quota() {
cooldown::reset_global_state().await;
let store = Arc::new(TaskStore::open_memory().await.unwrap());
for ext_id in ["1", "2"] {
store
.upsert_external(&UpsertExternal {
repo: "inactive/repo",
ext_id,
title: "Orphaned task",
body: "",
author: "user",
url: &format!("https://github.com/inactive/repo/issues/{ext_id}"),
labels: &[],
origin: "github",
})
.await
.unwrap();
}
let mut model_map: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
let mut tier = HashMap::new();
tier.insert("claude".to_string(), vec!["haiku".to_string()]);
model_map.insert("medium".to_string(), tier);
let config = RouterConfig {
mode: "round_robin".to_string(),
agents: vec!["claude".to_string()],
model_map,
..Default::default()
};
let mut router = Router::new_for_test(config, vec!["claude".to_string()]);
let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
let mut routing_quota = RoutingTickQuota::new(1);
route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
.await
.unwrap();
let all_tasks = store.list_all_global().await.unwrap();
let routed = all_tasks
.iter()
.filter(|task| task.status == TaskStatus::Routed)
.count();
let still_new = all_tasks
.iter()
.filter(|task| task.status == TaskStatus::New)
.count();
assert_eq!(routed, 1, "only one task may consume the shared quota");
assert_eq!(still_new, 1, "remaining tasks wait for the next tick");
route_new_tasks_global(&mut router, &store, &active_repos, &mut routing_quota)
.await
.unwrap();
let after_exhausted = store.list_all_global().await.unwrap();
assert_eq!(
after_exhausted
.iter()
.filter(|task| task.status == TaskStatus::Routed)
.count(),
1,
"an exhausted shared quota must not reset within the same tick"
);
}
#[tokio::test]
async fn group_orphaned_routed_tasks_selects_inactive_repo_tasks() {
async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
let id = store
.upsert_external(&UpsertExternal {
repo,
ext_id,
title: "Routed task",
body: "",
author: "user",
url: &format!("https://github.com/{repo}/issues/{ext_id}"),
labels: &[],
origin: "github",
})
.await
.unwrap();
store
.store_route(&StoreRoute {
id,
agent: "claude",
model: Some("sonnet"),
complexity: "medium",
estimate: 0,
reason: "test",
profile: "{}",
skills: "[]",
})
.await
.unwrap();
store.update_status(id, TaskStatus::Routed).await.unwrap();
id
}
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let active_id = make_routed(&store, "active/repo", "1").await;
let inactive_id_a = make_routed(&store, "inactive/repo", "2").await;
let other_inactive_id = make_routed(&store, "other-inactive/repo", "3").await;
let inactive_id_b = make_routed(&store, "inactive/repo", "4").await;
let all_routed = store
.list_all_by_status_global(TaskStatus::Routed)
.await
.unwrap();
let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
assert!(
!by_repo.contains_key("active/repo"),
"global sweep must not select tasks for the active repo"
);
let inactive_ids: HashSet<i64> = by_repo
.get("inactive/repo")
.expect("inactive/repo must be selected")
.iter()
.map(|t| t.id)
.collect();
assert_eq!(
inactive_ids,
[inactive_id_a, inactive_id_b].into_iter().collect(),
"global sweep must group all orphaned tasks for a repo together"
);
assert_eq!(
by_repo
.get("other-inactive/repo")
.map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
Some(vec![other_inactive_id])
);
assert_eq!(
store.get(active_id).await.unwrap().status,
TaskStatus::Routed
);
}
#[tokio::test]
async fn group_orphaned_routed_tasks_excludes_all_active_repos_not_just_current() {
async fn make_routed(store: &TaskStore, repo: &str, ext_id: &str) -> i64 {
let id = store
.upsert_external(&UpsertExternal {
repo,
ext_id,
title: "Routed task",
body: "",
author: "user",
url: &format!("https://github.com/{repo}/issues/{ext_id}"),
labels: &[],
origin: "github",
})
.await
.unwrap();
store
.store_route(&StoreRoute {
id,
agent: "claude",
model: Some("sonnet"),
complexity: "medium",
estimate: 0,
reason: "test",
profile: "{}",
skills: "[]",
})
.await
.unwrap();
store.update_status(id, TaskStatus::Routed).await.unwrap();
id
}
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let repo_a_id = make_routed(&store, "active/repo-a", "1").await;
let repo_b_id = make_routed(&store, "active/repo-b", "2").await;
let orphaned_id = make_routed(&store, "inactive/repo", "3").await;
let all_routed = store
.list_all_by_status_global(TaskStatus::Routed)
.await
.unwrap();
let active_repos: HashSet<String> =
["active/repo-a".to_string(), "active/repo-b".to_string()]
.into_iter()
.collect();
let by_repo = group_orphaned_routed_tasks(all_routed, &active_repos);
assert!(
!by_repo.contains_key("active/repo-a"),
"the current repo's own tasks must not be swept"
);
assert!(
!by_repo.contains_key("active/repo-b"),
"another active repo's tasks must not be swept during repo A's tick"
);
assert_eq!(
by_repo
.get("inactive/repo")
.map(|tasks| tasks.iter().map(|t| t.id).collect::<Vec<_>>()),
Some(vec![orphaned_id]),
"a genuinely inactive repo's tasks must still be swept"
);
assert_eq!(
store.get(repo_a_id).await.unwrap().status,
TaskStatus::Routed
);
assert_eq!(
store.get(repo_b_id).await.unwrap().status,
TaskStatus::Routed
);
}
#[tokio::test]
async fn global_dispatch_sweep_skips_no_agent_tasks() {
let store = Arc::new(TaskStore::open_memory().await.unwrap());
let id = store
.upsert_external(&UpsertExternal {
repo: "inactive/repo",
ext_id: "99",
title: "No-agent routed task",
body: "",
author: "user",
url: "https://github.com/inactive/repo/issues/99",
labels: &["no-agent".to_string()],
origin: "github",
})
.await
.unwrap();
store
.store_route(&StoreRoute {
id,
agent: "claude",
model: Some("sonnet"),
complexity: "medium",
estimate: 0,
reason: "test",
profile: "{}",
skills: "[]",
})
.await
.unwrap();
store.update_status(id, TaskStatus::Routed).await.unwrap();
let tmux = Arc::new(TmuxManager::new());
let transport = Arc::new(Transport::new());
let capture = Arc::new(CaptureService::new(transport));
let semaphore = Arc::new(Semaphore::new(4));
let (weight_tx, _weight_rx) = mpsc::channel(16);
let dispatching = Arc::new(DashMap::new());
let dispatch_mode = DispatchMode {
is_degraded: false,
healthy_agents: 1,
threshold: 1,
};
let active_repos: HashSet<String> = ["active/repo".to_string()].into_iter().collect();
dispatch_routed_tasks_global(
&tmux,
&capture,
&semaphore,
&weight_tx,
dispatch_mode,
&dispatching,
&store,
&HashMap::new(),
&active_repos,
)
.await
.unwrap();
assert_eq!(
store.get(id).await.unwrap().status,
TaskStatus::Routed,
"no-agent tasks must not be dispatched"
);
}
}