use async_trait::async_trait;
use orch::backends::{ExternalBackend, ExternalId, ExternalTask, Status};
use orch::store::TaskStore;
use std::sync::Arc;
struct MockBackend;
#[async_trait]
impl ExternalBackend for MockBackend {
fn name(&self) -> &str {
"mock"
}
async fn create_task(
&self,
_title: &str,
_body: &str,
_labels: &[String],
) -> anyhow::Result<ExternalId> {
Ok(ExternalId("mock-1".to_string()))
}
async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask> {
Ok(ExternalTask {
id: id.clone(),
title: "mock task".to_string(),
body: "".to_string(),
state: "open".to_string(),
labels: vec![],
author: "test".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
url: "".to_string(),
})
}
async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
Ok(vec![])
}
async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
Ok(())
}
async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
Ok(())
}
async fn remove_label(&self, _id: &ExternalId, _label: &str) -> anyhow::Result<()> {
Ok(())
}
async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
Ok(vec![])
}
async fn health_check(&self) -> anyhow::Result<()> {
Ok(())
}
}
#[tokio::test]
async fn store_opens_and_migrates() {
let tmp = temp_db("open");
let store = TaskStore::open(&tmp).await;
assert!(store.is_ok(), "TaskStore::open failed: {:?}", store.err());
let _ = std::fs::remove_file(&tmp);
let _ = std::fs::remove_file(tmp.with_extension("db-shm"));
let _ = std::fs::remove_file(tmp.with_extension("db-wal"));
}
#[tokio::test]
async fn store_migrations_idempotent() {
let tmp = temp_db("migration-idempotent");
{
let result = TaskStore::open(&tmp).await;
assert!(result.is_ok(), "first open failed: {:?}", result.err());
}
{
let result = TaskStore::open(&tmp).await;
assert!(
result.is_ok(),
"second open failed (checksum mismatch?): {:?}",
result.err()
);
}
let _ = std::fs::remove_file(&tmp);
let _ = std::fs::remove_file(tmp.with_extension("db-shm"));
let _ = std::fs::remove_file(tmp.with_extension("db-wal"));
}
#[tokio::test]
async fn store_tasks_table_exists() {
let tmp = temp_db("table");
let store = TaskStore::open_single(&tmp).await.expect("open store");
let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM tasks")
.fetch_one(store.pool())
.await
.expect("query tasks table");
assert_eq!(count, 0);
let _ = std::fs::remove_file(&tmp);
let _ = std::fs::remove_file(tmp.with_extension("db-shm"));
let _ = std::fs::remove_file(tmp.with_extension("db-wal"));
}
#[test]
fn router_config_parses() {
let config = orch::engine::router::config::RouterConfig::from_config();
assert!(!config.agents.is_empty(), "should have default agents");
assert!(
matches!(config.mode.as_str(), "llm" | "local" | "round_robin"),
"unsupported router mode: {}",
config.mode
);
}
#[tokio::test]
async fn mock_backend_health_check() {
let backend = MockBackend;
assert!(backend.health_check().await.is_ok());
}
#[tokio::test]
async fn cooldown_init_with_store() {
let tmp = temp_db("cooldown");
let store = Arc::new(TaskStore::open_single(&tmp).await.expect("open store"));
orch::engine::cooldown::init_cooldown_store(store).await;
let _ = std::fs::remove_file(&tmp);
let _ = std::fs::remove_file(tmp.with_extension("db-shm"));
let _ = std::fs::remove_file(tmp.with_extension("db-wal"));
}
fn temp_db(label: &str) -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("orch-integ-{label}-{}-{n}.db", std::process::id(),))
}
fn cleanup_db(path: &std::path::Path) {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(path.with_extension("db-shm"));
let _ = std::fs::remove_file(path.with_extension("db-wal"));
}
#[tokio::test]
async fn project_engine_constructs() {
use orch::engine::runner::TaskRunner;
use orch::engine::tasks::TaskManager;
use orch::engine::ProjectEngine;
let tmp = temp_db("pe-construct");
let store = Arc::new(TaskStore::open_single(&tmp).await.expect("open store"));
let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend);
let task_manager = Arc::new(TaskManager::with_store(
backend.clone(),
store.clone(),
"test/repo".to_string(),
));
let runner = Arc::new(TaskRunner::new("test/repo".to_string()).with_store(store.clone()));
let engine = ProjectEngine {
repo: "test/repo".to_string(),
project_dir: std::path::PathBuf::from("/tmp/test-project"),
backend,
task_manager,
runner,
store: store.clone(),
};
assert_eq!(engine.repo, "test/repo");
cleanup_db(&tmp);
}
#[tokio::test]
async fn task_manager_creates_internal_task() {
use orch::engine::tasks::TaskManager;
use orch::store::TaskStatus;
let tmp = temp_db("tm-create");
let store = Arc::new(TaskStore::open_single(&tmp).await.expect("open store"));
let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend);
let tm = TaskManager::with_store(backend, store.clone(), "test/repo".to_string());
let task_id = store
.create_internal(
"test/repo",
"Fix the widget",
"The widget is broken",
"test",
"1",
None,
)
.await
.expect("create internal task");
let task = store.get(task_id).await.expect("get task");
assert_eq!(task.title, "Fix the widget");
assert_eq!(task.status, TaskStatus::New);
assert_eq!(task.repo, "test/repo");
let routable = store
.list_routable("test/repo")
.await
.expect("list routable");
assert_eq!(routable.len(), 1);
assert_eq!(routable[0].id, task_id);
let internal = tm
.list_internal_by_status(TaskStatus::New)
.await
.expect("list internal");
assert_eq!(internal.len(), 1);
cleanup_db(&tmp);
}
#[tokio::test]
async fn create_external_task_and_retrieve() {
use orch::store::{NewTask, TaskStatus};
let tmp = temp_db("create-ext");
let store = Arc::new(TaskStore::open_single(&tmp).await.expect("open store"));
let id = store
.create(&NewTask {
external_id: Some("42".to_string()),
repo: "owner/repo".to_string(),
origin: "github".to_string(),
title: "Add dark mode".to_string(),
body: "Users want dark mode support".to_string(),
source: "webhook".to_string(),
source_id: "42".to_string(),
author: "user1".to_string(),
url: "https://github.com/owner/repo/issues/42".to_string(),
labels: vec!["enhancement".to_string()],
parent_id: None,
})
.await
.expect("create external task");
let task = store.get(id).await.expect("get task");
assert_eq!(task.title, "Add dark mode");
assert_eq!(task.status, TaskStatus::New);
assert_eq!(task.external_id.as_deref(), Some("42"));
assert_eq!(task.origin, "github");
assert_eq!(task.labels, vec!["enhancement"]);
let all = store
.list_all_external("owner/repo")
.await
.expect("list all external");
assert_eq!(all.len(), 1);
assert_eq!(all[0].title, "Add dark mode");
cleanup_db(&tmp);
}
#[tokio::test]
async fn task_status_lifecycle() {
use orch::store::TaskStatus;
let tmp = temp_db("status-lifecycle");
let store = Arc::new(TaskStore::open_single(&tmp).await.expect("open store"));
let id = store
.create_internal("test/repo", "Lifecycle test", "", "test", "lc-1", None)
.await
.expect("create task");
store
.update_status(id, TaskStatus::Routed)
.await
.expect("new → routed");
assert_eq!(store.get(id).await.unwrap().status, TaskStatus::Routed);
store
.update_status(id, TaskStatus::InProgress)
.await
.expect("routed → in_progress");
assert_eq!(store.get(id).await.unwrap().status, TaskStatus::InProgress);
store
.update_status(id, TaskStatus::NeedsReview)
.await
.expect("in_progress → needs_review");
assert_eq!(store.get(id).await.unwrap().status, TaskStatus::NeedsReview);
store
.update_status(id, TaskStatus::InReview)
.await
.expect("needs_review → in_review");
assert_eq!(store.get(id).await.unwrap().status, TaskStatus::InReview);
store
.update_status(id, TaskStatus::Done)
.await
.expect("in_review → done");
assert_eq!(store.get(id).await.unwrap().status, TaskStatus::Done);
cleanup_db(&tmp);
}
#[tokio::test]
async fn store_route_result_persists() {
use orch::store::{StoreRoute, TaskStatus};
let tmp = temp_db("route-persist");
let store = Arc::new(TaskStore::open_single(&tmp).await.expect("open store"));
let id = store
.create_internal("test/repo", "Route me", "task body", "test", "rt-1", None)
.await
.expect("create task");
store
.store_route(&StoreRoute {
id,
agent: "claude",
model: Some("sonnet"),
complexity: "medium",
estimate: 5,
reason: "test routing",
profile: r#"{"role":"backend specialist","skills":[],"tools":[],"constraints":[]}"#,
skills: r#"["gh","git-worktree"]"#,
})
.await
.expect("store route");
store
.update_status(id, TaskStatus::Routed)
.await
.expect("update to routed");
let task = store.get(id).await.expect("get routed task");
assert_eq!(task.status, TaskStatus::Routed);
assert_eq!(task.agent.as_deref(), Some("claude"));
assert_eq!(task.model.as_deref(), Some("sonnet"));
assert_eq!(task.complexity, "medium");
assert_eq!(task.route_reason, "test routing");
cleanup_db(&tmp);
}
#[tokio::test]
async fn router_round_robin_routes_task() {
use orch::engine::router::config::RouterConfig;
use orch::engine::router::Router;
use orch::store::UpsertExternal;
let tmp = temp_db("rr-route");
let store = Arc::new(TaskStore::open_single(&tmp).await.expect("open store"));
let mut config = RouterConfig {
mode: "round_robin".to_string(),
agents: vec!["claude".to_string(), "codex".to_string()],
..RouterConfig::default()
};
for agent in &["claude", "codex"] {
for tier in &["simple", "medium", "complex"] {
config
.model_map
.entry(tier.to_string())
.or_default()
.insert(agent.to_string(), vec!["sonnet".to_string()]);
}
}
let mut router = Router::new(config);
if router.available_agents.is_empty() {
return;
}
let ext = UpsertExternal {
repo: "test/repo",
ext_id: "99",
title: "Round-robin test task",
body: "Test body for routing",
author: "test",
url: "",
labels: &[],
origin: "github",
};
store.upsert_external(&ext).await.expect("upsert");
let task = ExternalTask {
id: ExternalId("99".to_string()),
title: "Round-robin test task".to_string(),
body: "Test body for routing".to_string(),
state: "open".to_string(),
labels: vec![],
author: "test".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
url: "".to_string(),
};
let result = router.route(&task, &store, "test/repo").await;
assert!(
result.is_ok(),
"round-robin routing should succeed: {:?}",
result.err()
);
let route = result.unwrap();
assert!(
router.available_agents.contains(&route.agent),
"routed agent {:?} should be in available agents {:?}",
route.agent,
router.available_agents
);
assert!(
!route.complexity.is_empty(),
"complexity should not be empty"
);
assert!(!route.reason.is_empty(), "reason should not be empty");
router
.store_route_result("99", &route, &store, "test/repo")
.await
.expect("store route result");
let stored = orch::engine::router::get_route_result(&store, "test/repo", "99")
.await
.expect("get route result");
assert_eq!(stored.agent, route.agent);
assert_eq!(stored.complexity, route.complexity);
cleanup_db(&tmp);
}
#[tokio::test]
async fn task_runner_constructs_with_store() {
use orch::engine::runner::TaskRunner;
let tmp = temp_db("runner-construct");
let store = Arc::new(TaskStore::open_single(&tmp).await.expect("open store"));
let _runner = TaskRunner::new("test/repo".to_string()).with_store(store);
cleanup_db(&tmp);
}
#[tokio::test]
async fn task_activity_tracking() {
let tmp = temp_db("activity");
let store = Arc::new(TaskStore::open_single(&tmp).await.expect("open store"));
let id = store
.create_internal("test/repo", "Activity test", "", "test", "act-1", None)
.await
.expect("create task");
store
.update_status(id, orch::store::TaskStatus::Routed)
.await
.expect("route");
store
.update_status(id, orch::store::TaskStatus::InProgress)
.await
.expect("dispatch");
let activity = store.get_activity(id, None).await.expect("get activity");
assert!(
!activity.is_empty(),
"activity log should not be empty after status changes"
);
cleanup_db(&tmp);
}
#[tokio::test]
async fn engine_tick_simulation() {
use orch::store::{StoreRoute, TaskStatus, UpsertExternal};
let tmp = temp_db("tick-sim");
let store = Arc::new(TaskStore::open_single(&tmp).await.expect("open store"));
let ext = UpsertExternal {
repo: "owner/repo",
ext_id: "101",
title: "Implement caching layer",
body: "We need Redis-backed caching for the API",
author: "engineer",
url: "https://github.com/owner/repo/issues/101",
labels: &["enhancement".to_string(), "backend".to_string()],
origin: "github",
};
let id = store.upsert_external(&ext).await.expect("upsert");
let routable = store
.list_routable("owner/repo")
.await
.expect("list routable");
assert_eq!(routable.len(), 1);
store
.store_route(&StoreRoute {
id,
agent: "claude",
model: Some("opus"),
complexity: "complex",
estimate: 8,
reason: "backend task requiring deep caching knowledge",
profile: r#"{"role":"backend specialist","skills":["redis"],"tools":["bash"],"constraints":[]}"#,
skills: r#"["gh"]"#,
})
.await
.expect("store route");
store
.update_status(id, TaskStatus::Routed)
.await
.expect("mark routed");
let routable_after = store
.list_routable("owner/repo")
.await
.expect("list routable after");
assert!(
routable_after.is_empty(),
"routed task should not appear in routable list"
);
let task = store.get(id).await.expect("get routed task");
assert_eq!(task.status, TaskStatus::Routed);
assert_eq!(task.agent.as_deref(), Some("claude"));
assert_eq!(task.model.as_deref(), Some("opus"));
store
.update_status(id, TaskStatus::InProgress)
.await
.expect("mark in_progress");
let dispatched = store.get(id).await.expect("get dispatched");
assert_eq!(dispatched.status, TaskStatus::InProgress);
store
.update_status(id, TaskStatus::NeedsReview)
.await
.expect("needs_review");
store
.update_status(id, TaskStatus::InReview)
.await
.expect("in_review");
store
.update_status(id, TaskStatus::Done)
.await
.expect("done");
let final_task = store.get(id).await.expect("get final");
assert_eq!(final_task.status, TaskStatus::Done);
let activity = store.get_activity(id, None).await.expect("activity");
assert!(
activity.len() >= 4,
"expected at least 4 activity entries for full lifecycle, got {}",
activity.len()
);
cleanup_db(&tmp);
}
#[tokio::test]
async fn task_blocked_with_reason() {
use orch::store::TaskStatus;
let tmp = temp_db("blocked");
let store = Arc::new(TaskStore::open_single(&tmp).await.expect("open store"));
let id = store
.create_internal("test/repo", "Blocked task", "", "test", "blk-1", None)
.await
.expect("create task");
store
.update_status(id, TaskStatus::Blocked)
.await
.expect("block");
store
.set_block_reason(id, Some("max review cycles exceeded"))
.await
.expect("set block reason");
let task = store.get(id).await.expect("get blocked task");
assert_eq!(task.status, TaskStatus::Blocked);
assert_eq!(
task.block_reason.as_deref(),
Some("max review cycles exceeded")
);
store
.update_status(id, TaskStatus::New)
.await
.expect("unblock");
store
.set_block_reason(id, None)
.await
.expect("clear block reason");
let unblocked = store.get(id).await.expect("get unblocked");
assert_eq!(unblocked.status, TaskStatus::New);
assert!(unblocked.block_reason.is_none());
cleanup_db(&tmp);
}
#[tokio::test]
async fn multi_repo_task_isolation() {
use orch::store::TaskStatus;
let tmp = temp_db("multi-repo");
let store = Arc::new(TaskStore::open_single(&tmp).await.expect("open store"));
store
.create_internal("repo-a/project", "Task A", "", "test", "a-1", None)
.await
.expect("create task A");
store
.create_internal("repo-b/project", "Task B", "", "test", "b-1", None)
.await
.expect("create task B");
let routable_a = store.list_routable("repo-a/project").await.expect("list A");
let routable_b = store.list_routable("repo-b/project").await.expect("list B");
assert_eq!(routable_a.len(), 1, "repo A should have 1 routable task");
assert_eq!(routable_b.len(), 1, "repo B should have 1 routable task");
assert_eq!(routable_a[0].title, "Task A");
assert_eq!(routable_b[0].title, "Task B");
store
.update_status(routable_a[0].id, TaskStatus::Routed)
.await
.expect("route A");
let routable_a_after = store
.list_routable("repo-a/project")
.await
.expect("list A after");
let routable_b_after = store
.list_routable("repo-b/project")
.await
.expect("list B after");
assert!(routable_a_after.is_empty(), "repo A should have 0 routable");
assert_eq!(
routable_b_after.len(),
1,
"repo B should still have 1 routable"
);
cleanup_db(&tmp);
}
#[test]
fn graceful_degradation_config_defaults() {
use orch::engine::router::config::*;
let threshold = min_healthy_agents_threshold();
assert_eq!(threshold, 2, "default threshold should be 2");
let delay = sequential_dispatch_delay_ms();
assert_eq!(delay, 1000, "default sequential delay should be 1000ms");
let base = retry_base_delay_ms();
assert_eq!(base, 10_000, "default retry base delay should be 10000ms");
let max = retry_max_delay_ms();
assert_eq!(max, 120_000, "default retry max delay should be 120000ms");
}
#[test]
fn exponential_backoff_calculation() {
use orch::engine::router::config::{retry_base_delay_ms, retry_max_delay_ms};
let base = retry_base_delay_ms();
let max = retry_max_delay_ms();
assert_eq!(base, 10_000, "base delay should be 10s");
assert_eq!(max, 120_000, "max delay should be 120s");
let growth_0 = base * 2u64.saturating_pow(0);
let growth_1 = base * 2u64.saturating_pow(1);
let growth_2 = base * 2u64.saturating_pow(2);
let growth_3 = base * 2u64.saturating_pow(3);
let growth_4 = base * 2u64.saturating_pow(4);
assert!(growth_1 > growth_0, "exponential should grow");
assert!(growth_2 > growth_1, "exponential should grow");
assert!(growth_3 > growth_2, "exponential should grow");
assert!(growth_4 > growth_3, "exponential should grow");
assert!(growth_4 >= max, "should be capped at max");
}
#[tokio::test]
async fn router_healthy_agent_count() {
use orch::engine::router::{Router, RouterConfig};
let config = RouterConfig::default();
let router = Router::new(config);
let healthy = router.healthy_agent_count("simple");
let available = router.available_agents.len();
assert!(
healthy <= available,
"healthy count ({}) should be <= available ({})",
healthy,
available
);
let _degraded_at_5 = router.is_degraded(5);
let degraded_at_1 = router.is_degraded(1);
assert!(
!degraded_at_1 || healthy == 0,
"is_degraded(1) should be false unless no healthy agents"
);
}
#[tokio::test]
async fn update_status_and_fields_records_pre_update_values_in_activity() {
use orch::store::{TaskActivity, TaskStatus};
let tmp = temp_db("update-activity");
let store = TaskStore::open_single(&tmp).await.expect("open store");
let id = store
.create_internal("test/repo", "decode-error test", "", "test", "job:1", None)
.await
.expect("create task");
store
.update_status_and_fields(
id,
TaskStatus::Routed,
&[("summary", serde_json::json!("initial summary"))],
)
.await
.expect("update_status_and_fields should succeed");
let activity: Vec<TaskActivity> = store.get_activity(id, None).await.expect("get_activity");
assert_eq!(activity.len(), 1, "expected one activity entry");
let entry = &activity[0];
assert_eq!(entry.event_type, "status_change");
assert_eq!(
entry.from_status.as_deref(),
Some("new"),
"from_status must be read from the row, not defaulted"
);
assert_eq!(entry.to_status.as_deref(), Some("routed"));
assert_eq!(entry.agent, None, "agent should be None, not a default");
assert_eq!(entry.model, None, "model should be None, not a default");
let _ = std::fs::remove_file(&tmp);
let _ = std::fs::remove_file(tmp.with_extension("db-shm"));
let _ = std::fs::remove_file(tmp.with_extension("db-wal"));
}
#[tokio::test]
async fn update_status_and_fields_propagates_fetch_error_on_missing_row() {
use orch::store::TaskStatus;
let tmp = temp_db("update-norow");
let store = TaskStore::open_single(&tmp).await.expect("open store");
let result = store
.update_status_and_fields(9999, TaskStatus::Routed, &[])
.await;
assert!(
result.is_err(),
"update_status_and_fields must return Err when the row does not exist"
);
let _ = std::fs::remove_file(&tmp);
let _ = std::fs::remove_file(tmp.with_extension("db-shm"));
let _ = std::fs::remove_file(tmp.with_extension("db-wal"));
}
#[tokio::test]
async fn update_status_and_fields_propagates_column_decode_error() {
use orch::store::{TaskActivity, TaskStatus};
let tmp = temp_db("update-decode");
let store = TaskStore::open_single(&tmp).await.expect("open store");
let id = store
.create_internal("test/repo", "decode-error test", "", "test", "job:1", None)
.await
.expect("create task");
sqlx::query("UPDATE tasks SET status = X'FFFFFFFF' WHERE id = ?")
.bind(id)
.execute(store.pool())
.await
.expect("inject non-UTF8 status");
let result = store
.update_status_and_fields(id, TaskStatus::Routed, &[])
.await;
assert!(
result.is_err(),
"update_status_and_fields must return Err when status column decode fails"
);
let activity: Vec<TaskActivity> = store.get_activity(id, None).await.expect("get_activity");
assert!(
activity.is_empty(),
"no activity should be written when pre-update column decode fails"
);
let status_row: (Vec<u8>,) = sqlx::query_as("SELECT status FROM tasks WHERE id = ?")
.bind(id)
.fetch_one(store.pool())
.await
.expect("fetch status raw");
assert_ne!(
std::str::from_utf8(&status_row.0).ok(),
Some("routed"),
"task status must remain unchanged when decode fails"
);
let _ = std::fs::remove_file(&tmp);
let _ = std::fs::remove_file(tmp.with_extension("db-shm"));
let _ = std::fs::remove_file(tmp.with_extension("db-wal"));
}