use std::sync::Arc;
use task_graph_mcp::config::workflows::WorkflowsConfig;
use task_graph_mcp::config::{
AppConfig, AttachmentsConfig, AutoAdvanceConfig, DependenciesConfig, FeedbackConfig, IdsConfig,
PhasesConfig, StatesConfig, TagsConfig,
};
use task_graph_mcp::db::Database;
use task_graph_mcp::db::tasks::ListTasksQuery;
use task_graph_mcp::types::PRIORITY_DEFAULT;
fn setup_db() -> Database {
Database::open_in_memory().expect("Failed to create in-memory database")
}
fn default_states_config() -> StatesConfig {
StatesConfig::default()
}
fn default_deps_config() -> DependenciesConfig {
DependenciesConfig::default()
}
fn default_auto_advance() -> AutoAdvanceConfig {
AutoAdvanceConfig::default()
}
fn default_ids_config() -> IdsConfig {
IdsConfig::default()
}
fn default_app_config() -> AppConfig {
AppConfig::new(
Arc::new(StatesConfig::default()),
Arc::new(PhasesConfig::default()),
Arc::new(DependenciesConfig::default()),
Arc::new(AutoAdvanceConfig::default()),
Arc::new(AttachmentsConfig::default()),
Arc::new(TagsConfig::default()),
Arc::new(IdsConfig::default()),
Arc::new(WorkflowsConfig::default()),
Arc::new(FeedbackConfig::default()),
)
}
mod agent_tests {
use super::*;
#[test]
fn register_worker_creates_agent_with_defaults() {
let db = setup_db();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.expect("Failed to register agent");
assert!(agent.tags.is_empty());
assert_eq!(agent.max_claims, i32::MAX); assert!(agent.registered_at > 0);
assert!(agent.last_heartbeat > 0);
}
#[test]
fn register_worker_with_custom_tags() {
let db = setup_db();
let agent = db
.register_worker(
None,
vec!["rust".to_string(), "backend".to_string()],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.expect("Failed to register agent");
assert_eq!(agent.tags, vec!["rust", "backend"]);
assert_eq!(agent.max_claims, i32::MAX); }
#[test]
fn register_worker_with_custom_id() {
let db = setup_db();
let agent = db
.register_worker(
Some("my-custom-agent".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.expect("Failed to register agent with custom ID");
assert_eq!(agent.id, "my-custom-agent");
}
#[test]
fn register_worker_rejects_id_over_64_chars() {
let db = setup_db();
let result = db.register_worker(
Some(
"this-worker-id-is-definitely-way-too-long-and-should-be-rejected-by-system"
.to_string(),
),
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
);
assert!(result.is_err());
}
#[test]
fn register_worker_rejects_empty_id() {
let db = setup_db();
let result = db.register_worker(
Some("".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
);
assert!(result.is_err());
}
#[test]
fn register_worker_rejects_duplicate_id() {
let db = setup_db();
let result = db.register_worker(
Some("duplicate-agent".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
);
assert!(result.is_ok());
let result = db.register_worker(
Some("duplicate-agent".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("already registered")
);
}
#[test]
fn register_worker_with_force_allows_reconnection() {
let db = setup_db();
let agent1 = db
.register_worker(
Some("force-agent".to_string()),
vec!["old-tag".to_string()],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
assert_eq!(agent1.tags, vec!["old-tag"]);
let result = db.register_worker(
Some("force-agent".to_string()),
vec!["new-tag".to_string()],
false,
&default_ids_config(),
None,
vec![],
Some(0),
);
assert!(result.is_err());
let agent2 = db
.register_worker(
Some("force-agent".to_string()),
vec!["new-tag".to_string()],
true,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
assert_eq!(agent2.tags, vec!["new-tag"]);
}
#[test]
fn get_worker_returns_registered_agent() {
let db = setup_db();
let agent = db
.register_worker(
None,
vec!["finder".to_string()],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let found = db.get_worker(&agent.id).unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().tags, vec!["finder"]);
}
#[test]
fn get_worker_returns_none_for_unknown_id() {
let db = setup_db();
let result = db.get_worker("unknown-agent-id").unwrap();
assert!(result.is_none());
}
#[test]
fn update_worker_modifies_properties() {
let db = setup_db();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let updated = db
.update_worker(&agent.id, Some(vec!["new-tag".to_string()]), Some(3))
.unwrap();
assert_eq!(updated.tags, vec!["new-tag"]);
assert_eq!(updated.max_claims, 3);
}
#[test]
fn heartbeat_updates_last_heartbeat_and_returns_claim_count() {
let db = setup_db();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let original_heartbeat = agent.last_heartbeat;
std::thread::sleep(std::time::Duration::from_millis(10));
let claim_count = db.heartbeat(&agent.id, &default_states_config()).unwrap();
assert_eq!(claim_count, 0);
let updated = db.get_worker(&agent.id).unwrap().unwrap();
assert!(updated.last_heartbeat >= original_heartbeat);
}
#[test]
fn heartbeat_fails_for_unknown_agent() {
let db = setup_db();
let result = db.heartbeat("unknown-agent", &default_states_config());
assert!(result.is_err());
}
#[test]
fn unregister_worker_removes_agent() {
let db = setup_db();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
db.unregister_worker(&agent.id, "pending").unwrap();
let found = db.get_worker(&agent.id).unwrap();
assert!(found.is_none());
}
#[test]
fn list_workers_returns_all_registered_agents() {
let db = setup_db();
db.register_worker(
None,
vec!["agent1".to_string()],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
db.register_worker(
None,
vec!["agent2".to_string()],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let agents = db.list_workers().unwrap();
assert_eq!(agents.len(), 2);
}
#[test]
fn auto_generated_worker_ids_are_unique_petnames() {
let db = setup_db();
let agent1 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let agent2 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let agent3 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
assert_ne!(agent1.id, agent2.id);
assert_ne!(agent2.id, agent3.id);
assert_ne!(agent1.id, agent3.id);
assert!(
agent1.id.chars().next().unwrap().is_uppercase(),
"Expected PascalCase petname, got: {}",
agent1.id
);
assert!(
!agent1.id.contains("0000"),
"ID looks like UUID, expected petname: {}",
agent1.id
);
assert!(
agent1.id.len() <= 64,
"ID too long, expected petname under 64 chars: {}",
agent1.id
);
}
#[test]
fn register_worker_stores_workflow_name() {
let db = setup_db();
let agent = db
.register_worker(
Some("workflow-worker".to_string()),
vec![],
false,
&default_ids_config(),
Some("swarm".to_string()),
vec![],
Some(0),
)
.expect("Failed to register agent with workflow");
assert_eq!(agent.workflow, Some("swarm".to_string()));
}
#[test]
fn register_worker_without_workflow_stores_none() {
let db = setup_db();
let agent = db
.register_worker(
Some("no-workflow-worker".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.expect("Failed to register agent without workflow");
assert!(agent.workflow.is_none());
}
#[test]
fn get_worker_returns_workflow_in_worker_struct() {
let db = setup_db();
let agent = db
.register_worker(
Some("get-workflow-worker".to_string()),
vec!["test-tag".to_string()],
false,
&default_ids_config(),
Some("coordinator".to_string()),
vec![],
Some(0),
)
.expect("Failed to register agent");
let found = db
.get_worker(&agent.id)
.expect("Failed to get worker")
.expect("Worker not found");
assert_eq!(found.workflow, Some("coordinator".to_string()));
assert_eq!(found.tags, vec!["test-tag"]);
}
#[test]
fn workflow_persists_across_force_reconnection() {
let db = setup_db();
let agent1 = db
.register_worker(
Some("reconnect-workflow-worker".to_string()),
vec!["old-tag".to_string()],
false,
&default_ids_config(),
Some("alpha".to_string()),
vec![],
Some(0),
)
.expect("Failed to register agent initially");
assert_eq!(agent1.workflow, Some("alpha".to_string()));
let agent2 = db
.register_worker(
Some("reconnect-workflow-worker".to_string()),
vec!["new-tag".to_string()],
true,
&default_ids_config(),
Some("beta".to_string()),
vec![],
Some(0),
)
.expect("Failed to force reconnect");
assert_eq!(agent2.workflow, Some("beta".to_string()));
assert_eq!(agent2.tags, vec!["new-tag"]);
let found = db
.get_worker("reconnect-workflow-worker")
.expect("Failed to get worker")
.expect("Worker not found");
assert_eq!(found.workflow, Some("beta".to_string()));
}
#[test]
fn workflow_can_be_cleared_on_force_reconnection() {
let db = setup_db();
let agent1 = db
.register_worker(
Some("clear-workflow-worker".to_string()),
vec![],
false,
&default_ids_config(),
Some("initial-workflow".to_string()),
vec![],
Some(0),
)
.expect("Failed to register agent");
assert_eq!(agent1.workflow, Some("initial-workflow".to_string()));
let agent2 = db
.register_worker(
Some("clear-workflow-worker".to_string()),
vec![],
true,
&default_ids_config(),
None,
vec![],
Some(0),
)
.expect("Failed to force reconnect");
assert!(agent2.workflow.is_none());
let found = db
.get_worker("clear-workflow-worker")
.expect("Failed to get worker")
.expect("Worker not found");
assert!(found.workflow.is_none());
}
#[test]
fn list_workers_returns_workflow_in_results() {
let db = setup_db();
db.register_worker(
Some("list-worker-1".to_string()),
vec!["team-a".to_string()],
false,
&default_ids_config(),
Some("workflow-alpha".to_string()),
vec![],
Some(0),
)
.expect("Failed to register first worker");
db.register_worker(
Some("list-worker-2".to_string()),
vec!["team-b".to_string()],
false,
&default_ids_config(),
Some("workflow-beta".to_string()),
vec![],
Some(0),
)
.expect("Failed to register second worker");
db.register_worker(
Some("list-worker-3".to_string()),
vec!["team-c".to_string()],
false,
&default_ids_config(),
None, vec![],
Some(0),
)
.expect("Failed to register third worker");
let workers = db.list_workers().expect("Failed to list workers");
assert_eq!(workers.len(), 3);
let worker1 = workers.iter().find(|w| w.id == "list-worker-1");
let worker2 = workers.iter().find(|w| w.id == "list-worker-2");
let worker3 = workers.iter().find(|w| w.id == "list-worker-3");
assert!(worker1.is_some());
assert!(worker2.is_some());
assert!(worker3.is_some());
assert_eq!(
worker1.unwrap().workflow,
Some("workflow-alpha".to_string())
);
assert_eq!(worker2.unwrap().workflow, Some("workflow-beta".to_string()));
assert!(worker3.unwrap().workflow.is_none());
}
#[test]
fn register_worker_with_overlays_stores_them() {
let db = setup_db();
let agent = db
.register_worker(
Some("overlay-worker".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec!["git".to_string(), "troubleshooting".to_string()],
Some(0),
)
.expect("Failed to register agent with overlays");
assert_eq!(
agent.overlays,
vec!["git".to_string(), "troubleshooting".to_string()]
);
}
#[test]
fn register_worker_without_overlays_stores_empty() {
let db = setup_db();
let agent = db
.register_worker(
Some("no-overlay-worker".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.expect("Failed to register agent without overlays");
assert!(agent.overlays.is_empty());
}
#[test]
fn get_worker_returns_overlays_in_worker_struct() {
let db = setup_db();
db.register_worker(
Some("get-overlay-worker".to_string()),
vec!["test-tag".to_string()],
false,
&default_ids_config(),
None,
vec!["git".to_string()],
Some(0),
)
.expect("Failed to register agent");
let found = db
.get_worker("get-overlay-worker")
.expect("Failed to get worker")
.expect("Worker not found");
assert_eq!(found.overlays, vec!["git".to_string()]);
assert_eq!(found.tags, vec!["test-tag"]);
}
#[test]
fn get_worker_returns_empty_overlays_when_none_stored() {
let db = setup_db();
db.register_worker(
Some("no-overlay-get-worker".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.expect("Failed to register agent");
let found = db
.get_worker("no-overlay-get-worker")
.expect("Failed to get worker")
.expect("Worker not found");
assert!(found.overlays.is_empty());
}
#[test]
fn update_worker_overlays_round_trip() {
let db = setup_db();
db.register_worker(
Some("update-overlay-worker".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.expect("Failed to register agent");
let updated = db
.update_worker_overlays(
"update-overlay-worker",
vec!["git".to_string(), "user-request".to_string()],
)
.expect("Failed to update overlays");
assert_eq!(
updated.overlays,
vec!["git".to_string(), "user-request".to_string()]
);
let found = db
.get_worker("update-overlay-worker")
.expect("Failed to get worker")
.expect("Worker not found");
assert_eq!(
found.overlays,
vec!["git".to_string(), "user-request".to_string()]
);
}
#[test]
fn update_worker_overlays_with_empty_list_stores_null() {
let db = setup_db();
db.register_worker(
Some("clear-overlay-worker".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec!["git".to_string()],
Some(0),
)
.expect("Failed to register agent");
let updated = db
.update_worker_overlays("clear-overlay-worker", vec![])
.expect("Failed to clear overlays");
assert!(updated.overlays.is_empty());
let found = db
.get_worker("clear-overlay-worker")
.expect("Failed to get worker")
.expect("Worker not found");
assert!(found.overlays.is_empty());
}
#[test]
fn update_worker_overlays_fails_for_unknown_worker() {
let db = setup_db();
let result = db.update_worker_overlays("nonexistent-worker", vec!["git".to_string()]);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Worker not found"));
}
#[test]
fn overlays_persist_across_force_reconnection() {
let db = setup_db();
let agent1 = db
.register_worker(
Some("reconnect-overlay-worker".to_string()),
vec!["old-tag".to_string()],
false,
&default_ids_config(),
None,
vec!["git".to_string()],
Some(0),
)
.expect("Failed to register agent initially");
assert_eq!(agent1.overlays, vec!["git".to_string()]);
let agent2 = db
.register_worker(
Some("reconnect-overlay-worker".to_string()),
vec!["new-tag".to_string()],
true,
&default_ids_config(),
None,
vec!["troubleshooting".to_string(), "user-request".to_string()],
Some(0),
)
.expect("Failed to force reconnect");
assert_eq!(
agent2.overlays,
vec!["troubleshooting".to_string(), "user-request".to_string()]
);
assert_eq!(agent2.tags, vec!["new-tag"]);
let found = db
.get_worker("reconnect-overlay-worker")
.expect("Failed to get worker")
.expect("Worker not found");
assert_eq!(
found.overlays,
vec!["troubleshooting".to_string(), "user-request".to_string()]
);
}
#[test]
fn overlays_can_be_cleared_on_force_reconnection() {
let db = setup_db();
let agent1 = db
.register_worker(
Some("clear-overlay-reconnect".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec!["git".to_string(), "troubleshooting".to_string()],
Some(0),
)
.expect("Failed to register agent");
assert_eq!(agent1.overlays.len(), 2);
let agent2 = db
.register_worker(
Some("clear-overlay-reconnect".to_string()),
vec![],
true,
&default_ids_config(),
None,
vec![],
Some(0),
)
.expect("Failed to force reconnect");
assert!(agent2.overlays.is_empty());
let found = db
.get_worker("clear-overlay-reconnect")
.expect("Failed to get worker")
.expect("Worker not found");
assert!(found.overlays.is_empty());
}
#[test]
fn list_workers_returns_overlays_in_results() {
let db = setup_db();
db.register_worker(
Some("list-overlay-1".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec!["git".to_string()],
Some(0),
)
.expect("Failed to register first worker");
db.register_worker(
Some("list-overlay-2".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec!["git".to_string(), "troubleshooting".to_string()],
Some(0),
)
.expect("Failed to register second worker");
db.register_worker(
Some("list-overlay-3".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![], Some(0),
)
.expect("Failed to register third worker");
let workers = db.list_workers().expect("Failed to list workers");
assert_eq!(workers.len(), 3);
let worker1 = workers.iter().find(|w| w.id == "list-overlay-1");
let worker2 = workers.iter().find(|w| w.id == "list-overlay-2");
let worker3 = workers.iter().find(|w| w.id == "list-overlay-3");
assert!(worker1.is_some());
assert!(worker2.is_some());
assert!(worker3.is_some());
assert_eq!(worker1.unwrap().overlays, vec!["git".to_string()]);
assert_eq!(
worker2.unwrap().overlays,
vec!["git".to_string(), "troubleshooting".to_string()]
);
assert!(worker3.unwrap().overlays.is_empty());
}
#[test]
fn update_worker_overlays_preserves_other_fields() {
let db = setup_db();
db.register_worker(
Some("preserve-fields-worker".to_string()),
vec!["rust".to_string(), "backend".to_string()],
false,
&default_ids_config(),
Some("swarm".to_string()),
vec![],
Some(0),
)
.expect("Failed to register agent");
let updated = db
.update_worker_overlays("preserve-fields-worker", vec!["git".to_string()])
.expect("Failed to update overlays");
assert_eq!(updated.overlays, vec!["git".to_string()]);
assert_eq!(
updated.tags,
vec!["rust".to_string(), "backend".to_string()]
);
assert_eq!(updated.workflow, Some("swarm".to_string()));
assert_eq!(updated.id, "preserve-fields-worker");
}
#[test]
fn update_worker_overlays_multiple_times() {
let db = setup_db();
db.register_worker(
Some("multi-overlay-worker".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.expect("Failed to register agent");
let updated1 = db
.update_worker_overlays("multi-overlay-worker", vec!["git".to_string()])
.expect("Failed to update overlays first time");
assert_eq!(updated1.overlays, vec!["git".to_string()]);
let updated2 = db
.update_worker_overlays(
"multi-overlay-worker",
vec!["git".to_string(), "troubleshooting".to_string()],
)
.expect("Failed to update overlays second time");
assert_eq!(
updated2.overlays,
vec!["git".to_string(), "troubleshooting".to_string()]
);
let updated3 = db
.update_worker_overlays("multi-overlay-worker", vec!["troubleshooting".to_string()])
.expect("Failed to update overlays third time");
assert_eq!(updated3.overlays, vec!["troubleshooting".to_string()]);
let found = db
.get_worker("multi-overlay-worker")
.expect("Failed to get worker")
.expect("Worker not found");
assert_eq!(found.overlays, vec!["troubleshooting".to_string()]);
}
}
mod task_tests {
use super::*;
#[test]
fn create_task_with_minimal_fields() {
let db = setup_db();
let states_config = default_states_config();
let task = db
.create_task(
None, "Test Task".to_string(), None, None, None, None, None, None, None, None, None, &states_config,
&default_ids_config(),
)
.unwrap();
assert_eq!(task.title, "Test Task");
assert_eq!(task.description, None);
assert_eq!(task.status, "pending");
assert_eq!(task.priority, PRIORITY_DEFAULT);
assert!(task.worker_id.is_none());
}
#[test]
fn create_task_with_all_fields() {
let db = setup_db();
let states_config = default_states_config();
let task = db
.create_task(
None, "Full Task - Description".to_string(), None, None, None, Some(8),
Some(5),
Some(3600000),
Some(vec!["rust".to_string()]), Some(vec!["backend".to_string()]), None, &states_config,
&default_ids_config(),
)
.unwrap();
assert_eq!(task.title, "Full Task - Description");
assert_eq!(task.description, None);
assert_eq!(task.priority, 8);
assert_eq!(task.points, Some(5));
assert_eq!(task.time_estimate_ms, Some(3600000));
assert_eq!(task.needed_tags, vec!["rust"]);
assert_eq!(task.wanted_tags, vec!["backend"]);
}
#[test]
fn create_task_with_parent_creates_contains_dependency() {
let db = setup_db();
let states_config = default_states_config();
let parent = db
.create_task(
None,
"Parent".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let child1 = db
.create_task(
None,
"Child 1".to_string(),
None,
Some(parent.id.clone()),
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let child2 = db
.create_task(
None,
"Child 2".to_string(),
None,
Some(parent.id.clone()),
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let children = db.get_children(&parent.id).unwrap();
assert_eq!(children.len(), 2);
assert!(children.iter().any(|c| c.id == child1.id));
assert!(children.iter().any(|c| c.id == child2.id));
}
#[test]
fn get_task_returns_existing_task() {
let db = setup_db();
let states_config = default_states_config();
let task = db
.create_task(
None,
"Find Me".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let found = db.get_task(&task.id).unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().title, "Find Me");
}
#[test]
fn get_task_returns_none_for_unknown_id() {
let db = setup_db();
let unknown_id = "non-existent-task-id";
let result = db.get_task(unknown_id).unwrap();
assert!(result.is_none());
}
#[test]
fn update_task_modifies_properties() {
let db = setup_db();
let states_config = default_states_config();
let task = db
.create_task(
None,
"Original".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let updated = db
.update_task(
&task.id,
Some("Updated".to_string()),
Some(Some("New Description".to_string())),
Some("working".to_string()),
Some(8),
None,
None,
&states_config,
)
.unwrap();
assert_eq!(updated.title, "Updated");
assert_eq!(updated.description, Some("New Description".to_string()));
assert_eq!(updated.status, "working");
assert_eq!(updated.priority, 8);
}
#[test]
fn update_task_to_completed_sets_completed_at() {
let db = setup_db();
let states_config = default_states_config();
let task = db
.create_task(
None,
"Complete Me".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
assert!(task.completed_at.is_none());
db.update_task(
&task.id,
None,
None,
Some("working".to_string()),
None,
None,
None,
&states_config,
)
.unwrap();
let updated = db
.update_task(
&task.id,
None,
None,
Some("completed".to_string()),
None,
None,
None,
&states_config,
)
.unwrap();
assert!(updated.completed_at.is_some());
}
#[test]
fn delete_task_removes_task() {
let db = setup_db();
let states_config = default_states_config();
let task = db
.create_task(
None,
"Delete Me".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.delete_task(&task.id, "test-worker", false, None, true, false)
.unwrap();
let found = db.get_task(&task.id).unwrap();
assert!(found.is_none());
}
#[test]
fn delete_task_without_cascade_fails_if_has_children() {
let db = setup_db();
let states_config = default_states_config();
let parent = db
.create_task(
None,
"Parent".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.create_task(
None,
"Child".to_string(),
None,
Some(parent.id.clone()),
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let result = db.delete_task(&parent.id, "test-worker", false, None, true, false);
assert!(result.is_err());
}
#[test]
fn delete_task_with_cascade_removes_children() {
let db = setup_db();
let states_config = default_states_config();
let parent = db
.create_task(
None,
"Parent".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let child = db
.create_task(
None,
"Child".to_string(),
None,
Some(parent.id.clone()),
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.delete_task(&parent.id, "test-worker", true, None, true, false)
.unwrap();
assert!(db.get_task(&parent.id).unwrap().is_none());
assert!(db.get_task(&child.id).unwrap().is_none());
}
#[test]
fn delete_task_obliterate_cascade_with_file_locks() {
let db = setup_db();
let states_config = default_states_config();
let agent = db
.register_worker(
Some("test-worker".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let parent = db
.create_task(
None,
"Parent".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let child = db
.create_task(
None,
"Child".to_string(),
None,
Some(parent.id.clone()),
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.lock_file(
"src/parent.rs".to_string(),
&agent.id,
Some(parent.id.clone()),
None,
)
.unwrap();
db.lock_file(
"src/child.rs".to_string(),
&agent.id,
Some(child.id.clone()),
None,
)
.unwrap();
db.delete_task(&parent.id, &agent.id, true, None, true, false)
.unwrap();
assert!(db.get_task(&parent.id).unwrap().is_none());
assert!(db.get_task(&child.id).unwrap().is_none());
}
#[test]
fn get_children_returns_direct_children_in_order() {
let db = setup_db();
let states_config = default_states_config();
let parent = db
.create_task(
None,
"Parent".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.create_task(
None,
"Child 1".to_string(),
None,
Some(parent.id.clone()),
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.create_task(
None,
"Child 2".to_string(),
None,
Some(parent.id.clone()),
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let mut children = db.get_children(&parent.id).unwrap();
children.sort_by(|a, b| a.title.cmp(&b.title));
assert_eq!(children.len(), 2);
assert_eq!(children[0].title, "Child 1");
assert_eq!(children[1].title, "Child 2");
}
#[test]
fn list_tasks_filters_by_status() {
let db = setup_db();
let states_config = default_states_config();
db.create_task(
None,
"Pending".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task2 = db
.create_task(
None,
"Completed".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.update_task(
&task2.id,
None,
None,
Some("working".to_string()),
None,
None,
None,
&states_config,
)
.unwrap();
db.update_task(
&task2.id,
None,
None,
Some("completed".to_string()),
None,
None,
None,
&states_config,
)
.unwrap();
let pending = db
.list_tasks(ListTasksQuery {
status: Some("pending"),
..Default::default()
})
.unwrap();
let completed = db
.list_tasks(ListTasksQuery {
status: Some("completed"),
..Default::default()
})
.unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].title, "Pending");
assert_eq!(completed.len(), 1);
assert_eq!(completed[0].title, "Completed");
}
fn create_n_tasks(db: &Database, n: usize) -> Vec<String> {
let states_config = default_states_config();
let ids_config = default_ids_config();
let mut ids = Vec::with_capacity(n);
for i in 0..n {
let id = format!("page-task-{}", i);
db.create_task(
Some(id.clone()),
format!("Task {}", i),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&ids_config,
)
.unwrap();
ids.push(id);
}
ids
}
#[test]
fn list_tasks_offset_zero_returns_from_beginning() {
let db = setup_db();
let ids = create_n_tasks(&db, 5);
let tasks = db
.list_tasks(ListTasksQuery {
offset: 0,
sort_by: Some("created_at"),
sort_order: Some("asc"),
..Default::default()
})
.unwrap();
assert_eq!(tasks.len(), 5);
assert_eq!(tasks[0].id, ids[0]);
assert_eq!(tasks[4].id, ids[4]);
}
#[test]
fn list_tasks_offset_skips_first_n_results() {
let db = setup_db();
let ids = create_n_tasks(&db, 5);
let tasks = db
.list_tasks(ListTasksQuery {
limit: Some(i32::MAX),
offset: 2,
sort_by: Some("created_at"),
sort_order: Some("asc"),
..Default::default()
})
.unwrap();
assert_eq!(tasks.len(), 3);
assert_eq!(tasks[0].id, ids[2]);
assert_eq!(tasks[1].id, ids[3]);
assert_eq!(tasks[2].id, ids[4]);
}
#[test]
fn list_tasks_offset_exceeding_total_returns_empty() {
let db = setup_db();
let _ids = create_n_tasks(&db, 5);
let tasks = db
.list_tasks(ListTasksQuery {
limit: Some(i32::MAX),
offset: 100,
sort_by: Some("created_at"),
sort_order: Some("asc"),
..Default::default()
})
.unwrap();
assert!(tasks.is_empty());
}
#[test]
fn list_tasks_offset_equal_to_count_returns_empty() {
let db = setup_db();
let _ids = create_n_tasks(&db, 5);
let tasks = db
.list_tasks(ListTasksQuery {
limit: Some(i32::MAX),
offset: 5,
sort_by: Some("created_at"),
sort_order: Some("asc"),
..Default::default()
})
.unwrap();
assert!(tasks.is_empty());
}
#[test]
fn list_tasks_limit_and_offset_pages_through_results() {
let db = setup_db();
let ids = create_n_tasks(&db, 7);
let page1 = db
.list_tasks(ListTasksQuery {
limit: Some(3),
offset: 0,
sort_by: Some("created_at"),
sort_order: Some("asc"),
..Default::default()
})
.unwrap();
assert_eq!(page1.len(), 3);
assert_eq!(page1[0].id, ids[0]);
assert_eq!(page1[1].id, ids[1]);
assert_eq!(page1[2].id, ids[2]);
let page2 = db
.list_tasks(ListTasksQuery {
limit: Some(3),
offset: 3,
sort_by: Some("created_at"),
sort_order: Some("asc"),
..Default::default()
})
.unwrap();
assert_eq!(page2.len(), 3);
assert_eq!(page2[0].id, ids[3]);
assert_eq!(page2[1].id, ids[4]);
assert_eq!(page2[2].id, ids[5]);
let page3 = db
.list_tasks(ListTasksQuery {
limit: Some(3),
offset: 6,
sort_by: Some("created_at"),
sort_order: Some("asc"),
..Default::default()
})
.unwrap();
assert_eq!(page3.len(), 1);
assert_eq!(page3[0].id, ids[6]);
let page4 = db
.list_tasks(ListTasksQuery {
limit: Some(3),
offset: 9,
sort_by: Some("created_at"),
sort_order: Some("asc"),
..Default::default()
})
.unwrap();
assert!(page4.is_empty());
}
#[test]
fn list_tasks_limit_without_offset_returns_first_n() {
let db = setup_db();
let ids = create_n_tasks(&db, 5);
let tasks = db
.list_tasks(ListTasksQuery {
limit: Some(2),
sort_by: Some("created_at"),
sort_order: Some("asc"),
..Default::default()
})
.unwrap();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].id, ids[0]);
assert_eq!(tasks[1].id, ids[1]);
}
#[test]
fn list_tasks_offset_with_status_filter() {
let db = setup_db();
let states_config = default_states_config();
let ids = create_n_tasks(&db, 5);
for id in &ids[0..2] {
db.update_task(
id,
None,
None,
Some("working".to_string()),
None,
None,
None,
&states_config,
)
.unwrap();
}
let tasks = db
.list_tasks(ListTasksQuery {
status: Some("pending"),
limit: Some(i32::MAX),
offset: 1,
sort_by: Some("created_at"),
sort_order: Some("asc"),
..Default::default()
})
.unwrap();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].id, ids[3]);
assert_eq!(tasks[1].id, ids[4]);
}
#[test]
fn list_tasks_tool_has_more_true_when_more_results_exist() {
use serde_json::json;
use task_graph_mcp::format::OutputFormat;
use task_graph_mcp::tools::tasks::list_tasks;
let db = setup_db();
let _ids = create_n_tasks(&db, 5);
let states_config = default_states_config();
let deps_config = default_deps_config();
let result = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Json,
json!({
"limit": 3,
"offset": 0,
"sort_by": "created_at",
"sort_order": "asc",
"format": "json"
}),
)
.unwrap()
.into_json();
let tasks = result["tasks"].as_array().unwrap();
assert_eq!(tasks.len(), 3);
assert_eq!(result["has_more"], json!(true));
assert_eq!(result["offset"], json!(0));
assert_eq!(result["limit"], json!(3));
}
#[test]
fn list_tasks_tool_has_more_false_at_end() {
use serde_json::json;
use task_graph_mcp::format::OutputFormat;
use task_graph_mcp::tools::tasks::list_tasks;
let db = setup_db();
let _ids = create_n_tasks(&db, 5);
let states_config = default_states_config();
let deps_config = default_deps_config();
let result = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Json,
json!({
"limit": 3,
"offset": 3,
"sort_by": "created_at",
"sort_order": "asc",
"format": "json"
}),
)
.unwrap()
.into_json();
let tasks = result["tasks"].as_array().unwrap();
assert_eq!(tasks.len(), 2);
assert_eq!(result["has_more"], json!(false));
assert_eq!(result["offset"], json!(3));
assert_eq!(result["limit"], json!(3));
}
#[test]
fn list_tasks_tool_has_more_false_exact_fit() {
use serde_json::json;
use task_graph_mcp::format::OutputFormat;
use task_graph_mcp::tools::tasks::list_tasks;
let db = setup_db();
let _ids = create_n_tasks(&db, 6);
let states_config = default_states_config();
let deps_config = default_deps_config();
let result = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Json,
json!({
"limit": 3,
"offset": 3,
"sort_by": "created_at",
"sort_order": "asc",
"format": "json"
}),
)
.unwrap()
.into_json();
let tasks = result["tasks"].as_array().unwrap();
assert_eq!(tasks.len(), 3);
assert_eq!(result["has_more"], json!(false));
}
#[test]
fn list_tasks_tool_paging_covers_all_results() {
use serde_json::json;
use task_graph_mcp::format::OutputFormat;
use task_graph_mcp::tools::tasks::list_tasks;
let db = setup_db();
let ids = create_n_tasks(&db, 5);
let states_config = default_states_config();
let deps_config = default_deps_config();
let mut all_ids: Vec<String> = Vec::new();
let mut current_offset = 0;
loop {
let result = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Json,
json!({
"limit": 2,
"offset": current_offset,
"sort_by": "created_at",
"sort_order": "asc",
"format": "json"
}),
)
.unwrap()
.into_json();
let tasks = result["tasks"].as_array().unwrap();
for t in tasks {
all_ids.push(t["id"].as_str().unwrap().to_string());
}
let has_more = result["has_more"].as_bool().unwrap();
if !has_more {
break;
}
current_offset += 2;
}
assert_eq!(all_ids.len(), 5);
for (i, id) in ids.iter().enumerate() {
assert_eq!(&all_ids[i], id);
}
}
#[test]
fn list_tasks_tool_no_limit_returns_all_with_has_more_false() {
use serde_json::json;
use task_graph_mcp::format::OutputFormat;
use task_graph_mcp::tools::tasks::list_tasks;
let db = setup_db();
let _ids = create_n_tasks(&db, 5);
let states_config = default_states_config();
let deps_config = default_deps_config();
let result = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Json,
json!({
"sort_by": "created_at",
"sort_order": "asc",
"format": "json"
}),
)
.unwrap()
.into_json();
let tasks = result["tasks"].as_array().unwrap();
assert_eq!(tasks.len(), 5);
assert_eq!(result["has_more"], json!(false));
assert_eq!(result["limit"], json!(null));
}
#[test]
fn list_tasks_tool_has_more_with_offset_in_middle() {
use serde_json::json;
use task_graph_mcp::format::OutputFormat;
use task_graph_mcp::tools::tasks::list_tasks;
let db = setup_db();
let ids = create_n_tasks(&db, 10);
let states_config = default_states_config();
let deps_config = default_deps_config();
let result = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Json,
json!({
"limit": 4,
"offset": 3,
"sort_by": "created_at",
"sort_order": "asc",
"format": "json"
}),
)
.unwrap()
.into_json();
let tasks = result["tasks"].as_array().unwrap();
assert_eq!(tasks.len(), 4);
assert_eq!(tasks[0]["id"].as_str().unwrap(), ids[3]);
assert_eq!(tasks[3]["id"].as_str().unwrap(), ids[6]);
assert_eq!(result["has_more"], json!(true));
}
#[test]
fn list_tasks_tool_markdown_shows_more_results_hint() {
use serde_json::json;
use task_graph_mcp::format::OutputFormat;
use task_graph_mcp::tools::tasks::list_tasks;
let db = setup_db();
let _ids = create_n_tasks(&db, 5);
let states_config = default_states_config();
let deps_config = default_deps_config();
let result = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Markdown,
json!({
"limit": 2,
"offset": 0,
"sort_by": "created_at",
"sort_order": "asc",
"format": "markdown"
}),
)
.unwrap()
.into_raw();
assert!(
result.contains("offset=2"),
"Markdown output should hint at next offset, got: {}",
result
);
}
#[test]
fn list_tasks_tool_offset_with_ready_path() {
use serde_json::json;
use task_graph_mcp::format::OutputFormat;
use task_graph_mcp::tools::tasks::list_tasks;
let db = setup_db();
let ids = create_n_tasks(&db, 5);
let states_config = default_states_config();
let deps_config = default_deps_config();
let result = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Json,
json!({
"ready": true,
"limit": 2,
"offset": 2,
"sort_by": "created_at",
"sort_order": "asc",
"format": "json"
}),
)
.unwrap()
.into_json();
let tasks = result["tasks"].as_array().unwrap();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0]["id"].as_str().unwrap(), ids[2]);
assert_eq!(tasks[1]["id"].as_str().unwrap(), ids[3]);
assert_eq!(result["has_more"], json!(true));
assert_eq!(result["offset"], json!(2));
}
#[test]
fn list_tasks_tool_offset_exceeding_ready_results_returns_empty() {
use serde_json::json;
use task_graph_mcp::format::OutputFormat;
use task_graph_mcp::tools::tasks::list_tasks;
let db = setup_db();
let _ids = create_n_tasks(&db, 3);
let states_config = default_states_config();
let deps_config = default_deps_config();
let result = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Json,
json!({
"ready": true,
"limit": 10,
"offset": 100,
"sort_by": "created_at",
"sort_order": "asc",
"format": "json"
}),
)
.unwrap()
.into_json();
let tasks = result["tasks"].as_array().unwrap();
assert!(tasks.is_empty());
assert_eq!(result["has_more"], json!(false));
}
#[test]
fn list_tasks_tool_has_more_with_ready_offset() {
use serde_json::json;
use task_graph_mcp::format::OutputFormat;
use task_graph_mcp::tools::tasks::list_tasks;
let db = setup_db();
let ids = create_n_tasks(&db, 5);
let states_config = default_states_config();
let deps_config = default_deps_config();
let result = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Json,
json!({
"ready": true,
"limit": 2,
"offset": 1,
"sort_by": "created_at",
"sort_order": "asc",
"format": "json"
}),
)
.unwrap()
.into_json();
let tasks = result["tasks"].as_array().unwrap();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0]["id"].as_str().unwrap(), ids[1]);
assert_eq!(tasks[1]["id"].as_str().unwrap(), ids[2]);
assert_eq!(result["has_more"], json!(true));
let result2 = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Json,
json!({
"ready": true,
"limit": 2,
"offset": 3,
"sort_by": "created_at",
"sort_order": "asc",
"format": "json"
}),
)
.unwrap()
.into_json();
let tasks2 = result2["tasks"].as_array().unwrap();
assert_eq!(tasks2.len(), 2);
assert_eq!(tasks2[0]["id"].as_str().unwrap(), ids[3]);
assert_eq!(tasks2[1]["id"].as_str().unwrap(), ids[4]);
assert_eq!(result2["has_more"], json!(false));
}
#[test]
fn list_tasks_tool_offset_with_blocked_path() {
use serde_json::json;
use task_graph_mcp::format::OutputFormat;
use task_graph_mcp::tools::tasks::list_tasks;
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let ids_config = default_ids_config();
let blocker = db
.create_task(
Some("blocker".to_string()),
"Blocker Task".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&ids_config,
)
.unwrap();
let mut blocked_ids = Vec::new();
for i in 0..4 {
let id = format!("blocked-{}", i);
db.create_task(
Some(id.clone()),
format!("Blocked Task {}", i),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&ids_config,
)
.unwrap();
db.add_dependency(&blocker.id, &id, "blocks", &deps_config)
.unwrap();
blocked_ids.push(id);
}
let result = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Json,
json!({
"blocked": true,
"limit": 2,
"offset": 1,
"sort_by": "created_at",
"sort_order": "asc",
"format": "json"
}),
)
.unwrap()
.into_json();
let tasks = result["tasks"].as_array().unwrap();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0]["id"].as_str().unwrap(), blocked_ids[1]);
assert_eq!(tasks[1]["id"].as_str().unwrap(), blocked_ids[2]);
assert_eq!(result["has_more"], json!(true));
}
#[test]
fn list_tasks_filters_satisfied_blockers() {
use serde_json::json;
use task_graph_mcp::format::OutputFormat;
use task_graph_mcp::tools::tasks::list_tasks;
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let ids_config = default_ids_config();
let auto_advance = default_auto_advance();
let blocker_a = db
.create_task(
Some("blocker-a".to_string()),
"Blocker A".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&ids_config,
)
.unwrap();
let blocker_b = db
.create_task(
Some("blocker-b".to_string()),
"Blocker B".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&ids_config,
)
.unwrap();
let blocked_task = db
.create_task(
Some("blocked-task".to_string()),
"Blocked Task".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&ids_config,
)
.unwrap();
db.add_dependency(&blocker_a.id, &blocked_task.id, "blocks", &deps_config)
.unwrap();
db.add_dependency(&blocker_b.id, &blocked_task.id, "blocks", &deps_config)
.unwrap();
let result = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Json,
json!({
"status": "pending",
"format": "json"
}),
)
.unwrap()
.into_json();
let tasks = result["tasks"].as_array().unwrap();
let blocked = tasks
.iter()
.find(|t| t["id"].as_str().unwrap() == "blocked-task")
.unwrap();
let blocked_by = blocked["blocked_by"].as_array().unwrap();
assert_eq!(blocked_by.len(), 2, "Should show 2 unsatisfied blockers");
assert_eq!(blocked["blocked"].as_bool().unwrap(), true);
let worker = db
.register_worker(
Some("test-worker".to_string()),
vec![],
false,
&ids_config,
None,
vec![],
None,
)
.unwrap();
db.claim_task(&blocker_a.id, &worker.id, &states_config)
.unwrap();
db.update_task_unified(
&blocker_a.id,
&worker.id,
None, None, None, Some("completed".to_string()), None, None, None, None, None, None, None, None, false, &states_config,
&deps_config,
&auto_advance,
)
.unwrap();
let result = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Json,
json!({
"status": "pending",
"format": "json"
}),
)
.unwrap()
.into_json();
let tasks = result["tasks"].as_array().unwrap();
let blocked = tasks
.iter()
.find(|t| t["id"].as_str().unwrap() == "blocked-task")
.unwrap();
let blocked_by = blocked["blocked_by"].as_array().unwrap();
assert_eq!(
blocked_by.len(),
1,
"Should only show 1 unsatisfied blocker after completing blocker-a"
);
assert_eq!(blocked_by[0].as_str().unwrap(), "blocker-b");
assert_eq!(blocked["blocked"].as_bool().unwrap(), true);
db.claim_task(&blocker_b.id, &worker.id, &states_config)
.unwrap();
db.update_task_unified(
&blocker_b.id,
&worker.id,
None, None, None, Some("completed".to_string()), None, None, None, None, None, None, None, None, false, &states_config,
&deps_config,
&auto_advance,
)
.unwrap();
let result = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Json,
json!({
"status": "pending",
"format": "json"
}),
)
.unwrap()
.into_json();
let tasks = result["tasks"].as_array().unwrap();
let blocked = tasks
.iter()
.find(|t| t["id"].as_str().unwrap() == "blocked-task")
.unwrap();
let blocked_by = blocked["blocked_by"].as_array().unwrap();
assert_eq!(
blocked_by.len(),
0,
"Should show no blockers after all blockers are completed"
);
assert_eq!(blocked["blocked"].as_bool().unwrap(), false);
}
#[test]
fn list_tasks_markdown_hides_satisfied_blockers() {
use serde_json::json;
use task_graph_mcp::format::OutputFormat;
use task_graph_mcp::tools::tasks::list_tasks;
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let ids_config = default_ids_config();
let auto_advance = default_auto_advance();
let blocker = db
.create_task(
Some("md-blocker".to_string()),
"MD Blocker".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&ids_config,
)
.unwrap();
db.create_task(
Some("md-blocked".to_string()),
"MD Blocked Task".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&ids_config,
)
.unwrap();
db.add_dependency(&blocker.id, "md-blocked", "blocks", &deps_config)
.unwrap();
let result = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Markdown,
json!({
"status": "pending",
"format": "markdown"
}),
)
.unwrap()
.into_raw();
assert!(
result.contains("[blocked by"),
"Should show blocked annotation when blocker is pending"
);
let worker = db
.register_worker(
Some("md-worker".to_string()),
vec![],
false,
&ids_config,
None,
vec![],
None,
)
.unwrap();
db.claim_task(&blocker.id, &worker.id, &states_config)
.unwrap();
db.update_task_unified(
&blocker.id,
&worker.id,
None, None, None, Some("completed".to_string()), None, None, None, None, None, None, None, None, false, &states_config,
&deps_config,
&auto_advance,
)
.unwrap();
let result = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Markdown,
json!({
"status": "pending",
"format": "markdown"
}),
)
.unwrap()
.into_raw();
assert!(
result.contains("MD Blocked Task"),
"Should still show the blocked task"
);
assert!(
!result.contains("[blocked by"),
"Should NOT show blocked annotation after blocker is completed"
);
}
#[test]
fn list_tasks_tool_offset_with_claimed_path() {
use serde_json::json;
use task_graph_mcp::format::OutputFormat;
use task_graph_mcp::tools::tasks::list_tasks;
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let ids_config = default_ids_config();
let agent = db
.register_worker(
Some("claimer".to_string()),
vec![],
false,
&ids_config,
None,
vec![],
Some(0),
)
.unwrap();
let mut claimed_ids = Vec::new();
for i in 0..4 {
let id = format!("claim-task-{}", i);
db.create_task(
Some(id.clone()),
format!("Claimed Task {}", i),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&ids_config,
)
.unwrap();
db.claim_task(&id, &agent.id, &states_config).unwrap();
claimed_ids.push(id);
}
let result = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Json,
json!({
"claimed": true,
"limit": 10,
"offset": 2,
"format": "json"
}),
)
.unwrap()
.into_json();
let tasks = result["tasks"].as_array().unwrap();
assert_eq!(tasks.len(), 2);
assert_eq!(result["has_more"], json!(false));
}
#[test]
fn list_tasks_tool_json_next_offset_fields() {
use serde_json::json;
use task_graph_mcp::format::OutputFormat;
use task_graph_mcp::tools::tasks::list_tasks;
let db = setup_db();
let _ids = create_n_tasks(&db, 8);
let states_config = default_states_config();
let deps_config = default_deps_config();
let result = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Json,
json!({
"limit": 3,
"offset": 0,
"sort_by": "created_at",
"sort_order": "asc",
"format": "json"
}),
)
.unwrap()
.into_json();
assert_eq!(result["offset"], json!(0));
assert_eq!(result["limit"], json!(3));
assert_eq!(result["has_more"], json!(true));
let next_offset = result["offset"].as_i64().unwrap() + result["limit"].as_i64().unwrap();
assert_eq!(next_offset, 3);
let result2 = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Json,
json!({
"limit": 3,
"offset": 3,
"sort_by": "created_at",
"sort_order": "asc",
"format": "json"
}),
)
.unwrap()
.into_json();
assert_eq!(result2["offset"], json!(3));
assert_eq!(result2["limit"], json!(3));
assert_eq!(result2["has_more"], json!(true));
let next_offset2 = result2["offset"].as_i64().unwrap() + result2["limit"].as_i64().unwrap();
assert_eq!(next_offset2, 6);
let result3 = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Json,
json!({
"limit": 3,
"offset": 6,
"sort_by": "created_at",
"sort_order": "asc",
"format": "json"
}),
)
.unwrap()
.into_json();
assert_eq!(result3["offset"], json!(6));
assert_eq!(result3["limit"], json!(3));
assert_eq!(result3["has_more"], json!(false));
let tasks3 = result3["tasks"].as_array().unwrap();
assert_eq!(tasks3.len(), 2);
}
#[test]
fn list_tasks_tool_markdown_next_offset_on_second_page() {
use serde_json::json;
use task_graph_mcp::format::OutputFormat;
use task_graph_mcp::tools::tasks::list_tasks;
let db = setup_db();
let _ids = create_n_tasks(&db, 10);
let states_config = default_states_config();
let deps_config = default_deps_config();
let result = list_tasks(
&db,
&states_config,
&deps_config,
OutputFormat::Markdown,
json!({
"limit": 3,
"offset": 3,
"sort_by": "created_at",
"sort_order": "asc",
"format": "markdown"
}),
)
.unwrap()
.into_raw();
assert!(
result.contains("offset=6"),
"Second page should hint at next_offset=6 (3+3), got: {}",
result
);
}
#[test]
fn create_tool_stores_needed_and_wanted_tags() {
use serde_json::json;
use task_graph_mcp::tools::tasks::create;
let db = setup_db();
let app_config = default_app_config();
let args = json!({
"description": "Task with tags",
"needed_tags": ["backend", "admin"],
"wanted_tags": ["testing", "senior"]
});
let result = create(&db, &app_config, args).expect("create should succeed");
let task_id = result
.get("id")
.and_then(|v| v.as_str())
.expect("result should have id");
let task = db.get_task(task_id).unwrap().expect("task should exist");
assert_eq!(task.needed_tags, vec!["backend", "admin"]);
assert_eq!(task.wanted_tags, vec!["testing", "senior"]);
}
}
mod task_claiming_tests {
use super::*;
#[test]
fn claim_task_assigns_owner_and_updates_status() {
let db = setup_db();
let states_config = default_states_config();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Claim Me".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let claimed = db.claim_task(&task.id, &agent.id, &states_config).unwrap();
assert_eq!(claimed.worker_id, Some(agent.id.clone()));
assert_eq!(claimed.status, "working");
assert!(claimed.claimed_at.is_some());
assert!(claimed.started_at.is_some());
}
#[test]
fn claim_task_fails_if_already_claimed() {
let db = setup_db();
let states_config = default_states_config();
let agent1 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let agent2 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Claimed".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&task.id, &agent1.id, &states_config).unwrap();
let result = db.claim_task(&task.id, &agent2.id, &states_config);
assert!(result.is_err());
}
#[test]
fn claim_task_fails_if_agent_missing_needed_tag() {
let db = setup_db();
let states_config = default_states_config();
let agent = db
.register_worker(
None,
vec!["python".to_string()],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Rust Task".to_string(),
None,
None,
None, None,
None,
None,
Some(vec!["rust".to_string()]), None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let result = db.claim_task(&task.id, &agent.id, &states_config);
assert!(result.is_err());
}
#[test]
fn claim_task_succeeds_if_agent_has_needed_tags() {
let db = setup_db();
let states_config = default_states_config();
let agent = db
.register_worker(
None,
vec!["rust".to_string(), "backend".to_string()],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Rust Task".to_string(),
None,
None,
None, None,
None,
None,
Some(vec!["rust".to_string()]),
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let result = db.claim_task(&task.id, &agent.id, &states_config);
assert!(result.is_ok());
}
#[test]
fn claim_task_fails_if_agent_has_none_of_wanted_tags() {
let db = setup_db();
let states_config = default_states_config();
let agent = db
.register_worker(
None,
vec!["python".to_string()],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Flexible Task".to_string(),
None,
None,
None, None,
None,
None,
None,
Some(vec!["rust".to_string(), "go".to_string()]), None,
&states_config,
&default_ids_config(),
)
.unwrap();
let result = db.claim_task(&task.id, &agent.id, &states_config);
assert!(result.is_err());
}
#[test]
fn release_task_clears_owner_and_resets_status() {
let db = setup_db();
let states_config = default_states_config();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Release Me".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&task.id, &agent.id, &states_config).unwrap();
db.release_task(&task.id, &agent.id, &states_config)
.unwrap();
let updated = db.get_task(&task.id).unwrap().unwrap();
assert!(updated.worker_id.is_none());
assert_eq!(updated.status, "pending");
}
#[test]
fn release_task_fails_if_not_owner() {
let db = setup_db();
let states_config = default_states_config();
let agent1 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let agent2 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Owned".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&task.id, &agent1.id, &states_config).unwrap();
let result = db.release_task(&task.id, &agent2.id, &states_config);
assert!(result.is_err());
}
#[test]
fn force_release_clears_owner_regardless() {
let db = setup_db();
let states_config = default_states_config();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Force".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&task.id, &agent.id, &states_config).unwrap();
db.force_release(&task.id, &states_config).unwrap();
let updated = db.get_task(&task.id).unwrap().unwrap();
assert!(updated.worker_id.is_none());
}
#[test]
fn update_to_timed_state_claims_task() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Update Claim".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let (updated, _unblocked, auto_advanced, _auto_completed) = db
.update_task_unified(
&task.id,
&agent.id,
None, None,
None,
Some("working".to_string()),
None, None,
None,
None,
None,
None,
None,
None, false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(updated.status, "working");
assert_eq!(updated.worker_id, Some(agent.id.clone()));
assert!(updated.claimed_at.is_some());
assert!(auto_advanced.is_empty()); }
#[test]
fn update_from_timed_to_non_timed_releases_task() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Update Release".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.update_task_unified(
&task.id,
&agent.id,
None, None,
None,
Some("working".to_string()),
None, None,
None,
None,
None,
None,
None,
None, false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
let (updated, _, _, _) = db
.update_task_unified(
&task.id,
&agent.id,
None, None,
None,
Some("pending".to_string()),
None, None,
None,
None,
None,
None,
None,
None, false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(updated.status, "pending");
assert!(updated.worker_id.is_none());
assert!(updated.claimed_at.is_none());
}
#[test]
fn update_with_force_claims_from_another_agent() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let agent1 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let agent2 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Force Update".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&task.id, &agent1.id, &states_config).unwrap();
let (updated, _, _, _) = db
.update_task_unified(
&task.id,
&agent2.id,
None, None,
None,
Some("working".to_string()),
None, None,
None,
None,
None,
None,
None,
None, true, &states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(updated.worker_id, Some(agent2.id.clone()));
}
#[test]
fn update_without_force_fails_if_claimed_by_another() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let agent1 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let agent2 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"No Force".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&task.id, &agent1.id, &states_config).unwrap();
let result = db.update_task_unified(
&task.id,
&agent2.id,
None, None,
None,
Some("working".to_string()),
None, None,
None,
None,
None,
None,
None,
None, false, &states_config,
&deps_config,
&auto_advance,
);
assert!(result.is_err());
}
#[test]
fn update_validates_tag_affinity_on_claim() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let agent = db
.register_worker(
None,
vec!["python".to_string()],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Needs Rust".to_string(),
None,
None,
None, None,
None,
None,
Some(vec!["rust".to_string()]), None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let result = db.update_task_unified(
&task.id,
&agent.id,
None, None,
None,
Some("working".to_string()),
None, None,
None,
None,
None,
None,
None,
None, false,
&states_config,
&deps_config,
&auto_advance,
);
assert!(result.is_err());
}
#[test]
fn update_to_completed_clears_ownership() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Complete Me".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&task.id, &agent.id, &states_config).unwrap();
let (updated, _, _, _) = db
.update_task_unified(
&task.id,
&agent.id,
None, None,
None,
Some("completed".to_string()),
None, None,
None,
None,
None,
None,
None,
None, false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(updated.status, "completed");
assert!(updated.worker_id.is_none());
assert!(updated.completed_at.is_some());
}
#[test]
fn update_between_two_timed_states_preserves_ownership() {
use std::collections::HashMap;
use task_graph_mcp::config::StateDefinition;
let db = setup_db();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let mut definitions = HashMap::new();
definitions.insert(
"pending".to_string(),
StateDefinition {
exits: vec!["working".to_string(), "cancelled".to_string()],
timed: false,
},
);
definitions.insert(
"working".to_string(),
StateDefinition {
exits: vec![
"reviewing".to_string(),
"completed".to_string(),
"failed".to_string(),
"pending".to_string(),
],
timed: true,
},
);
definitions.insert(
"reviewing".to_string(),
StateDefinition {
exits: vec![
"working".to_string(),
"completed".to_string(),
"failed".to_string(),
],
timed: true, },
);
definitions.insert(
"completed".to_string(),
StateDefinition {
exits: vec![],
timed: false,
},
);
definitions.insert(
"failed".to_string(),
StateDefinition {
exits: vec!["pending".to_string()],
timed: false,
},
);
definitions.insert(
"cancelled".to_string(),
StateDefinition {
exits: vec![],
timed: false,
},
);
let states_config = task_graph_mcp::config::StatesConfig {
initial: "pending".to_string(),
disconnect_state: "pending".to_string(),
blocking_states: vec![
"pending".to_string(),
"working".to_string(),
"reviewing".to_string(),
],
definitions,
};
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Timed to Timed".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let (updated, _, _, _) = db
.update_task_unified(
&task.id,
&agent.id,
None, None,
None,
Some("working".to_string()),
None, None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(updated.status, "working");
assert_eq!(updated.worker_id, Some(agent.id.clone()));
let (updated, _, _, _) = db
.update_task_unified(
&task.id,
&agent.id,
None, None,
None,
Some("reviewing".to_string()),
None, None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(updated.status, "reviewing");
assert_eq!(updated.worker_id, Some(agent.id.clone())); assert!(updated.claimed_at.is_some());
let history = db.get_task_state_history(&task.id).unwrap();
assert!(
history.len() >= 3,
"Expected at least 3 history entries, got {}",
history.len()
);
let states: Vec<&str> = history
.iter()
.map(|e| e.status.as_deref().unwrap_or(""))
.collect();
assert!(
states.contains(&"pending"),
"History should contain 'pending'"
);
assert!(
states.contains(&"working"),
"History should contain 'working'"
);
assert!(
states.contains(&"reviewing"),
"History should contain 'reviewing'"
);
}
#[test]
fn update_to_same_state_succeeds() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Same State".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&task.id, &agent.id, &states_config).unwrap();
let claimed = db.get_task(&task.id).unwrap().unwrap();
assert_eq!(claimed.status, "working");
let (updated, _, _, _) = db
.update_task_unified(
&task.id,
&agent.id,
None, None,
None,
Some("working".to_string()),
None, None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(updated.status, "working");
assert_eq!(updated.worker_id, Some(agent.id.clone()));
let history = db.get_task_state_history(&task.id).unwrap();
let working_count = history
.iter()
.filter(|e| e.status.as_deref() == Some("working"))
.count();
assert_eq!(
working_count, 1,
"Should only have one working entry, not duplicates"
);
}
#[test]
fn update_between_two_untimed_states() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Untimed to Untimed".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&task.id, &agent.id, &states_config).unwrap();
let (failed_task, _, _, _) = db
.update_task_unified(
&task.id,
&agent.id,
None, None,
None,
Some("failed".to_string()),
None, None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(failed_task.status, "failed");
assert!(failed_task.worker_id.is_none());
let (updated, _, _, _) = db
.update_task_unified(
&task.id,
&agent.id,
None, None,
None,
Some("pending".to_string()),
None, None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(updated.status, "pending");
assert!(updated.worker_id.is_none()); assert!(updated.claimed_at.is_none());
let history = db.get_task_state_history(&task.id).unwrap();
assert!(
history.len() >= 4,
"Expected at least 4 history entries, got {}",
history.len()
);
let states: Vec<&str> = history
.iter()
.map(|e| e.status.as_deref().unwrap_or(""))
.collect();
assert!(
states.contains(&"failed"),
"History should contain 'failed'"
);
let pending_count = states.iter().filter(|&&s| s == "pending").count();
assert!(
pending_count >= 2,
"Should have at least 2 pending entries (initial + after failed)"
);
}
#[test]
fn claim_fails_if_blocked_by_single_task() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task_a = db
.create_task(
None,
"Task A".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task_b = db
.create_task(
None,
"Task B".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.add_dependency(&task_a.id, &task_b.id, "blocks", &deps_config)
.unwrap();
let result = db.update_task_unified(
&task_b.id,
&agent.id,
None,
None,
None,
Some("working".to_string()),
None, None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
);
assert!(
result.is_err(),
"Claim should fail when task has unsatisfied dependencies"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("unsatisfied dependencies") && err_msg.contains(&task_a.id),
"Error should mention unsatisfied dependencies and the blocking task ID. Got: {}",
err_msg
);
}
#[test]
fn claim_fails_if_blocked_by_chain() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task_a = db
.create_task(
None,
"Task A".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task_b = db
.create_task(
None,
"Task B".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task_c = db
.create_task(
None,
"Task C".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.add_dependency(&task_a.id, &task_b.id, "blocks", &deps_config)
.unwrap();
db.add_dependency(&task_b.id, &task_c.id, "blocks", &deps_config)
.unwrap();
let result = db.update_task_unified(
&task_c.id,
&agent.id,
None,
None,
None,
Some("working".to_string()),
None, None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
);
assert!(
result.is_err(),
"Claim on C should fail when B is still in blocking state"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("unsatisfied dependencies") && err_msg.contains(&task_b.id),
"Error should mention unsatisfied dependencies and the blocking task B ID. Got: {}",
err_msg
);
}
#[test]
fn claim_succeeds_after_blocker_completes() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task_a = db
.create_task(
None,
"Task A".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task_b = db
.create_task(
None,
"Task B".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.add_dependency(&task_a.id, &task_b.id, "blocks", &deps_config)
.unwrap();
db.claim_task(&task_a.id, &agent.id, &states_config)
.unwrap();
db.update_task_unified(
&task_a.id,
&agent.id,
None,
None,
None,
Some("completed".to_string()),
None, None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
let result = db.update_task_unified(
&task_b.id,
&agent.id,
None,
None,
None,
Some("working".to_string()),
None, None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
);
assert!(
result.is_ok(),
"Claim should succeed after blocking task is completed"
);
let (task, _, _, _) = result.unwrap();
assert_eq!(task.status, "working");
assert_eq!(task.worker_id.as_deref(), Some(agent.id.as_str()));
}
#[test]
fn claim_with_force_bypasses_dependency_check() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task_a = db
.create_task(
None,
"Task A".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task_b = db
.create_task(
None,
"Task B".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.add_dependency(&task_a.id, &task_b.id, "blocks", &deps_config)
.unwrap();
let result = db.update_task_unified(
&task_b.id,
&agent.id,
None,
None,
None,
Some("working".to_string()),
None, None,
None,
None,
None,
None,
None,
None,
true, &states_config,
&deps_config,
&auto_advance,
);
assert!(
result.is_ok(),
"Claim with force=true should succeed even when task has unsatisfied dependencies"
);
let (task, _, _, _) = result.unwrap();
assert_eq!(task.status, "working");
}
}
mod dependency_tests {
use super::*;
#[test]
fn add_dependency_creates_relationship() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let task1 = db
.create_task(
None,
"Task 1".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task2 = db
.create_task(
None,
"Task 2".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.add_dependency(&task1.id, &task2.id, "blocks", &deps_config)
.unwrap();
let blockers = db.get_blockers(&task2.id).unwrap();
assert_eq!(blockers.len(), 1);
assert_eq!(blockers[0], task1.id);
}
#[test]
fn add_dependency_fails_if_would_create_cycle() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let task1 = db
.create_task(
None,
"Task 1".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task2 = db
.create_task(
None,
"Task 2".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.add_dependency(&task1.id, &task2.id, "blocks", &deps_config)
.unwrap();
let result = db.add_dependency(&task2.id, &task1.id, "blocks", &deps_config);
assert!(result.is_err());
}
#[test]
fn add_dependency_fails_for_longer_cycles() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let task1 = db
.create_task(
None,
"Task 1".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task2 = db
.create_task(
None,
"Task 2".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task3 = db
.create_task(
None,
"Task 3".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.add_dependency(&task1.id, &task2.id, "blocks", &deps_config)
.unwrap(); db.add_dependency(&task2.id, &task3.id, "blocks", &deps_config)
.unwrap();
let result = db.add_dependency(&task3.id, &task1.id, "blocks", &deps_config);
assert!(result.is_err());
}
#[test]
fn remove_dependency_removes_relationship() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let task1 = db
.create_task(
None,
"Task 1".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task2 = db
.create_task(
None,
"Task 2".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.add_dependency(&task1.id, &task2.id, "blocks", &deps_config)
.unwrap();
db.remove_dependency(&task1.id, &task2.id, "blocks")
.unwrap();
let blockers = db.get_blockers(&task2.id).unwrap();
assert!(blockers.is_empty());
}
#[test]
fn get_ready_tasks_excludes_blocked_tasks() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let task1 = db
.create_task(
None,
"Blocker".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task2 = db
.create_task(
None,
"Blocked".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.add_dependency(&task1.id, &task2.id, "blocks", &deps_config)
.unwrap();
let ready = db
.get_ready_tasks(None, &states_config, &deps_config, None, None)
.unwrap();
assert_eq!(ready.len(), 1);
assert_eq!(ready[0].id, task1.id);
}
#[test]
fn get_ready_tasks_includes_unblocked_after_completion() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let task1 = db
.create_task(
None,
"Blocker".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task2 = db
.create_task(
None,
"Blocked".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.add_dependency(&task1.id, &task2.id, "blocks", &deps_config)
.unwrap();
db.update_task(
&task1.id,
None,
None,
Some("working".to_string()),
None,
None,
None,
&states_config,
)
.unwrap();
db.update_task(
&task1.id,
None,
None,
Some("completed".to_string()),
None,
None,
None,
&states_config,
)
.unwrap();
let ready = db
.get_ready_tasks(None, &states_config, &deps_config, None, None)
.unwrap();
assert_eq!(ready.len(), 1);
assert_eq!(ready[0].id, task2.id);
}
#[test]
fn get_ready_tasks_excludes_container_tasks() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let container = db
.create_task(
None,
"Stream A: Core Features".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let child1 = db
.create_task(
None,
"Implement feature X".to_string(),
None,
Some(container.id.clone()),
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let child2 = db
.create_task(
None,
"Implement feature Y".to_string(),
None,
Some(container.id.clone()),
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let leaf = db
.create_task(
None,
"Fix bug Z".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let ready = db
.get_ready_tasks(None, &states_config, &deps_config, None, None)
.unwrap();
let ready_ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
assert!(
!ready_ids.contains(&container.id.as_str()),
"Container task should be excluded from ready results, got: {:?}",
ready_ids
);
assert!(
ready_ids.contains(&child1.id.as_str()),
"Child task 1 should be in ready results"
);
assert!(
ready_ids.contains(&child2.id.as_str()),
"Child task 2 should be in ready results"
);
assert!(
ready_ids.contains(&leaf.id.as_str()),
"Standalone leaf task should be in ready results"
);
}
#[test]
fn get_ready_tasks_includes_task_after_children_removed() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let parent = db
.create_task(
None,
"Was a container".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let child = db
.create_task(
None,
"Child task".to_string(),
None,
Some(parent.id.clone()),
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let ready = db
.get_ready_tasks(None, &states_config, &deps_config, None, None)
.unwrap();
let ready_ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
assert!(
!ready_ids.contains(&parent.id.as_str()),
"Parent with child should not be ready"
);
assert!(
ready_ids.contains(&child.id.as_str()),
"Child should be ready"
);
db.remove_dependency(&parent.id, &child.id, "contains")
.unwrap();
let ready = db
.get_ready_tasks(None, &states_config, &deps_config, None, None)
.unwrap();
let ready_ids: Vec<&str> = ready.iter().map(|t| t.id.as_str()).collect();
assert!(
ready_ids.contains(&parent.id.as_str()),
"Parent without children should now be ready"
);
}
}
mod file_lock_tests {
use super::*;
#[test]
fn lock_file_creates_lock() {
let db = setup_db();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let warning = db
.lock_file("src/main.rs".to_string(), &agent.id, None, None)
.unwrap();
assert!(warning.is_none());
let locks = db.get_file_locks(None, Some(&agent.id), None).unwrap();
assert_eq!(locks.len(), 1);
assert!(locks.contains_key("src/main.rs"));
}
#[test]
fn lock_file_returns_warning_if_locked_by_another() {
let db = setup_db();
let agent1 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let agent2 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
db.lock_file("src/main.rs".to_string(), &agent1.id, None, None)
.unwrap();
let warning = db
.lock_file("src/main.rs".to_string(), &agent2.id, None, None)
.unwrap();
assert!(warning.is_some());
assert_eq!(warning.unwrap(), agent1.id);
}
#[test]
fn lock_file_updates_timestamp_if_same_agent() {
let db = setup_db();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
db.lock_file("src/main.rs".to_string(), &agent.id, None, None)
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
let warning = db
.lock_file("src/main.rs".to_string(), &agent.id, None, None)
.unwrap();
assert!(warning.is_none()); }
#[test]
fn unlock_file_removes_lock() {
let db = setup_db();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
db.lock_file("src/main.rs".to_string(), &agent.id, None, None)
.unwrap();
let unlocked = db.unlock_file("src/main.rs", &agent.id, None).unwrap();
assert!(unlocked);
let locks = db.get_file_locks(None, None, None).unwrap();
assert!(locks.is_empty());
}
#[test]
fn unlock_file_fails_for_wrong_agent() {
let db = setup_db();
let agent1 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let agent2 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
db.lock_file("src/main.rs".to_string(), &agent1.id, None, None)
.unwrap();
let unlocked = db.unlock_file("src/main.rs", &agent2.id, None).unwrap();
assert!(!unlocked);
}
#[test]
fn get_file_locks_filters_by_agent() {
let db = setup_db();
let agent1 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let agent2 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
db.lock_file("file1.rs".to_string(), &agent1.id, None, None)
.unwrap();
db.lock_file("file2.rs".to_string(), &agent2.id, None, None)
.unwrap();
let agent1_locks = db.get_file_locks(None, Some(&agent1.id), None).unwrap();
assert_eq!(agent1_locks.len(), 1);
assert!(agent1_locks.contains_key("file1.rs"));
}
#[test]
fn release_worker_locks_removes_all_agent_locks() {
let db = setup_db();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
db.lock_file("file1.rs".to_string(), &agent.id, None, None)
.unwrap();
db.lock_file("file2.rs".to_string(), &agent.id, None, None)
.unwrap();
let released = db.release_worker_locks(&agent.id).unwrap();
assert_eq!(released, 2);
let locks = db.get_file_locks(None, None, None).unwrap();
assert!(locks.is_empty());
}
#[test]
fn claim_updates_returns_immediately() {
let db = setup_db();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let start = std::time::Instant::now();
let updates = db.claim_updates(&agent.id).unwrap();
let elapsed = start.elapsed();
assert!(elapsed.as_millis() < 100);
assert!(updates.new_claims.is_empty());
assert!(updates.dropped_claims.is_empty());
}
#[test]
fn claim_updates_returns_new_claims() {
let db = setup_db();
let agent1 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let agent2 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
db.lock_file("test.rs".to_string(), &agent1.id, None, None)
.unwrap();
let start = std::time::Instant::now();
let updates = db.claim_updates(&agent2.id).unwrap();
let elapsed = start.elapsed();
assert!(
elapsed.as_millis() < 100,
"Expected immediate return, but elapsed: {:?}",
elapsed
);
assert_eq!(updates.new_claims.len(), 1);
assert_eq!(updates.new_claims[0].file_path, "test.rs");
}
#[test]
fn claim_updates_shows_release_for_claim_before_registration() {
let db = setup_db();
let agent1 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
db.lock_file("edge.rs".to_string(), &agent1.id, None, None)
.unwrap();
let agent2 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
db.unlock_file("edge.rs", &agent1.id, None).unwrap();
let updates = db.claim_updates(&agent2.id).unwrap();
assert!(
updates.new_claims.is_empty(),
"Agent2 should not see the claim"
);
assert_eq!(
updates.dropped_claims.len(),
1,
"Agent2 should see release so they know file is available"
);
assert_eq!(updates.dropped_claims[0].file_path, "edge.rs");
}
#[test]
fn claim_updates_includes_release_for_previously_polled_claim() {
let db = setup_db();
let agent1 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let agent2 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
db.lock_file("polled.rs".to_string(), &agent1.id, None, None)
.unwrap();
let updates1 = db.claim_updates(&agent2.id).unwrap();
assert_eq!(updates1.new_claims.len(), 1);
assert_eq!(updates1.new_claims[0].file_path, "polled.rs");
db.unlock_file("polled.rs", &agent1.id, None).unwrap();
let updates2 = db.claim_updates(&agent2.id).unwrap();
assert!(updates2.new_claims.is_empty());
assert_eq!(
updates2.dropped_claims.len(),
1,
"Should see release for previously polled claim"
);
assert_eq!(updates2.dropped_claims[0].file_path, "polled.rs");
}
#[test]
fn claim_updates_includes_release_when_claim_in_same_batch() {
let db = setup_db();
let agent1 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let agent2 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
db.lock_file("batch.rs".to_string(), &agent1.id, None, None)
.unwrap();
db.unlock_file("batch.rs", &agent1.id, None).unwrap();
let updates = db.claim_updates(&agent2.id).unwrap();
assert_eq!(updates.new_claims.len(), 1, "Should see the claim");
assert_eq!(updates.new_claims[0].file_path, "batch.rs");
assert_eq!(
updates.dropped_claims.len(),
1,
"Should see the release (claim in same batch)"
);
assert_eq!(updates.dropped_claims[0].file_path, "batch.rs");
}
#[test]
fn claim_updates_new_agent_only_sees_future_events() {
let db = setup_db();
let agent1 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
db.lock_file("old.rs".to_string(), &agent1.id, None, None)
.unwrap();
db.unlock_file("old.rs", &agent1.id, None).unwrap();
let agent2 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
db.lock_file("new.rs".to_string(), &agent1.id, None, None)
.unwrap();
let updates = db.claim_updates(&agent2.id).unwrap();
assert_eq!(updates.new_claims.len(), 1, "Should only see new.rs");
assert_eq!(updates.new_claims[0].file_path, "new.rs");
assert!(
updates.dropped_claims.is_empty(),
"Should not see old.rs release"
);
}
#[test]
fn regression_unmark_file_after_mark_file() {
let db = setup_db();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let lock_result = db.lock_file(
"test.rs".to_string(),
&agent.id,
Some("testing".to_string()),
None,
);
assert!(lock_result.is_ok(), "lock_file should succeed");
let unlock_result = db.unlock_file("test.rs", &agent.id, None);
assert!(
unlock_result.is_ok(),
"unlock_file should succeed (was failing with end_timestamp column error)"
);
assert!(unlock_result.unwrap(), "unlock should return true");
}
#[test]
fn regression_mark_updates_after_mark_file() {
let db = setup_db();
let agent1 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let agent2 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
db.lock_file(
"test.rs".to_string(),
&agent1.id,
Some("testing".to_string()),
None,
)
.unwrap();
let updates = db.claim_updates(&agent2.id);
assert!(
updates.is_ok(),
"claim_updates should succeed (was failing with end_timestamp column error)"
);
let updates = updates.unwrap();
assert_eq!(updates.new_claims.len(), 1, "Should see the new claim");
assert_eq!(updates.new_claims[0].file_path, "test.rs");
}
#[test]
fn regression_end_timestamp_populated_on_unlock() {
let db = setup_db();
let agent1 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let agent2 = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
db.lock_file("test.rs".to_string(), &agent1.id, None, None)
.unwrap();
db.unlock_file("test.rs", &agent1.id, None).unwrap();
let updates = db.claim_updates(&agent2.id).unwrap();
assert_eq!(updates.new_claims.len(), 1, "Should see the claim");
assert_eq!(updates.dropped_claims.len(), 1, "Should see the release");
let claim_event = &updates.new_claims[0];
assert!(
claim_event.end_timestamp.is_some(),
"Claim event should have end_timestamp set after release"
);
}
}
mod tracking_tests {
use super::*;
#[test]
fn set_thought_updates_current_thought() {
let db = setup_db();
let states_config = default_states_config();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Think".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&task.id, &agent.id, &states_config).unwrap();
db.set_thought(&agent.id, Some("Thinking...".to_string()), None)
.unwrap();
let updated = db.get_task(&task.id).unwrap().unwrap();
assert_eq!(updated.current_thought, Some("Thinking...".to_string()));
}
#[test]
fn log_time_accumulates_duration() {
let db = setup_db();
let states_config = default_states_config();
let task = db
.create_task(
None,
"Time Me".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.log_time(&task.id, 1000).unwrap();
db.log_time(&task.id, 2000).unwrap();
let updated = db.get_task(&task.id).unwrap().unwrap();
assert_eq!(updated.time_actual_ms, Some(3000));
}
#[test]
fn log_cost_accumulates_tokens_and_cost() {
let db = setup_db();
let states_config = default_states_config();
let task = db
.create_task(
None,
"Cost Me".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.log_metrics(
&task.id,
Some(0.001),
&[100, 0, 50], )
.unwrap();
db.log_metrics(
&task.id,
Some(0.002),
&[200, 0, 100], )
.unwrap();
let updated = db.get_task(&task.id).unwrap().unwrap();
assert_eq!(updated.metrics[0], 300); assert_eq!(updated.metrics[2], 150); assert!((updated.cost_usd - 0.003).abs() < 0.0001);
}
}
mod stats_tests {
use super::*;
#[test]
fn get_stats_returns_aggregate_statistics() {
let db = setup_db();
let states_config = default_states_config();
db.create_task(
None,
"Task 1".to_string(),
None,
None,
None, None,
Some(3),
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task2 = db
.create_task(
None,
"Task 2".to_string(),
None,
None,
None, None,
Some(5),
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.update_task(
&task2.id,
None,
None,
Some("working".to_string()),
None,
None,
None,
&states_config,
)
.unwrap();
db.update_task(
&task2.id,
None,
None,
Some("completed".to_string()),
None,
None,
None,
&states_config,
)
.unwrap();
let stats = db.get_stats(None, None, &states_config).unwrap();
assert_eq!(stats.total_tasks, 2);
assert_eq!(*stats.tasks_by_status.get("pending").unwrap_or(&0), 1);
assert_eq!(*stats.tasks_by_status.get("completed").unwrap_or(&0), 1);
assert_eq!(stats.total_points, 8);
assert_eq!(stats.completed_points, 5);
}
#[test]
fn get_stats_filters_by_agent() {
let db = setup_db();
let states_config = default_states_config();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Agent Task".to_string(),
None,
None,
None, None,
Some(3),
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&task.id, &agent.id, &states_config).unwrap();
db.create_task(
None,
"Other Task".to_string(),
None,
None,
None, None,
Some(5),
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let stats = db.get_stats(Some(&agent.id), None, &states_config).unwrap();
assert_eq!(stats.total_tasks, 1);
assert_eq!(stats.total_points, 3);
}
#[test]
fn get_stats_filters_by_task_tree() {
let db = setup_db();
let states_config = default_states_config();
let parent = db
.create_task(
None,
"Parent".to_string(),
None,
None,
None, None,
Some(2),
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.create_task(
None,
"Child".to_string(),
None,
Some(parent.id.clone()),
None, None,
Some(3),
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.create_task(
None,
"Other".to_string(),
None,
None,
None, None,
Some(10),
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let stats = db
.get_stats(None, Some(&parent.id), &states_config)
.unwrap();
assert_eq!(stats.total_tasks, 2); assert_eq!(stats.total_points, 5); }
}
mod state_transition_tests {
use super::*;
use std::thread::sleep;
use std::time::Duration;
#[test]
fn create_task_records_initial_pending_state() {
let db = setup_db();
let states_config = default_states_config();
let task = db
.create_task(
None,
"Test".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let history = db.get_task_state_history(&task.id).unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].status.as_deref().unwrap(), "pending");
assert!(history[0].end_timestamp.is_none()); }
#[test]
fn claim_task_records_working_transition() {
let db = setup_db();
let states_config = default_states_config();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Test".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&task.id, &agent.id, &states_config).unwrap();
let history = db.get_task_state_history(&task.id).unwrap();
assert_eq!(history.len(), 2);
assert_eq!(history[0].status.as_deref().unwrap(), "pending");
assert!(history[0].end_timestamp.is_some()); assert_eq!(history[1].status.as_deref().unwrap(), "working");
assert!(history[1].worker_id.is_some());
}
#[test]
fn complete_task_accumulates_time_from_working() {
let db = setup_db();
let states_config = default_states_config();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Test".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&task.id, &agent.id, &states_config).unwrap();
sleep(Duration::from_millis(100));
db.complete_task(&task.id, &agent.id, &states_config)
.unwrap();
let updated = db.get_task(&task.id).unwrap().unwrap();
assert!(updated.time_actual_ms.unwrap() >= 100);
let history = db.get_task_state_history(&task.id).unwrap();
assert_eq!(history.len(), 3);
assert_eq!(history[2].status.as_deref().unwrap(), "completed");
}
#[test]
fn multiple_claim_cycles_accumulate_time() {
let db = setup_db();
let states_config = default_states_config();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Test".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&task.id, &agent.id, &states_config).unwrap();
sleep(Duration::from_millis(50));
db.release_task_with_state(&task.id, &agent.id, "pending", &states_config)
.unwrap();
db.force_claim_task(&task.id, &agent.id, &states_config)
.unwrap();
sleep(Duration::from_millis(50));
db.complete_task(&task.id, &agent.id, &states_config)
.unwrap();
let updated = db.get_task(&task.id).unwrap().unwrap();
assert!(updated.time_actual_ms.unwrap() >= 100);
let history = db.get_task_state_history(&task.id).unwrap();
assert_eq!(history.len(), 5);
}
#[test]
fn release_to_non_working_state_accumulates_time() {
let db = setup_db();
let states_config = default_states_config();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Test".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&task.id, &agent.id, &states_config).unwrap();
sleep(Duration::from_millis(100));
db.release_task_with_state(&task.id, &agent.id, "failed", &states_config)
.unwrap();
let updated = db.get_task(&task.id).unwrap().unwrap();
assert!(updated.time_actual_ms.unwrap() >= 100);
}
#[test]
fn current_state_duration_returns_elapsed_time_for_working_state() {
let db = setup_db();
let states_config = default_states_config();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Test".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let duration = db
.get_current_state_duration(&task.id, &states_config)
.unwrap();
assert!(duration.is_none());
db.claim_task(&task.id, &agent.id, &states_config).unwrap();
sleep(Duration::from_millis(50));
let duration = db
.get_current_state_duration(&task.id, &states_config)
.unwrap();
assert!(duration.is_some());
assert!(duration.unwrap() >= 50);
}
#[test]
fn update_task_status_records_transition() {
let db = setup_db();
let states_config = default_states_config();
let task = db
.create_task(
None,
"Test".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.update_task(
&task.id,
None,
None,
Some("cancelled".to_string()),
None,
None,
None,
&states_config,
)
.unwrap();
let history = db.get_task_state_history(&task.id).unwrap();
assert_eq!(history.len(), 2);
assert_eq!(history[0].status.as_deref().unwrap(), "pending");
assert_eq!(history[1].status.as_deref().unwrap(), "cancelled");
}
#[test]
fn reopen_completed_task_to_pending() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task = db
.create_task(
None,
"Test reopen".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&task.id, &agent.id, &states_config).unwrap();
db.complete_task(&task.id, &agent.id, &states_config)
.unwrap();
let completed_task = db.get_task(&task.id).unwrap().unwrap();
assert_eq!(completed_task.status, "completed");
let (updated, _, _, _) = db
.update_task_unified(
&task.id,
&agent.id,
None, None,
None,
Some("pending".to_string()),
None, None,
None,
None,
None,
None,
None,
Some("Task needs rework".to_string()), false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(updated.status, "pending");
assert!(updated.worker_id.is_none());
let history = db.get_task_state_history(&task.id).unwrap();
let states: Vec<&str> = history
.iter()
.map(|e| e.status.as_deref().unwrap_or(""))
.collect();
assert!(
states.contains(&"pending")
&& states.contains(&"working")
&& states.contains(&"completed"),
"Expected pending, working, and completed in history, got {:?}",
states
);
let last_event = history.last().unwrap();
assert_eq!(last_event.status.as_deref().unwrap(), "pending");
assert_eq!(last_event.reason.as_deref(), Some("Task needs rework"));
}
}
mod auto_advance_tests {
use super::*;
fn auto_advance_enabled(target_state: &str) -> AutoAdvanceConfig {
AutoAdvanceConfig {
enabled: true,
target_state: Some(target_state.to_string()),
auto_rollup: false,
}
}
#[test]
fn unblocked_reported_even_when_auto_advance_disabled() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance(); let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task1 = db
.create_task(
None,
"Blocker".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task2 = db
.create_task(
None,
"Blocked".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.add_dependency(&task1.id, &task2.id, "blocks", &deps_config)
.unwrap();
db.claim_task(&task1.id, &agent.id, &states_config).unwrap();
let (_, unblocked, auto_advanced, _) = db
.update_task_unified(
&task1.id,
&agent.id,
None, None,
None,
Some("completed".to_string()),
None, None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(unblocked.len(), 1);
assert_eq!(unblocked[0], task2.id);
assert!(auto_advanced.is_empty());
let task2_updated = db.get_task(&task2.id).unwrap().unwrap();
assert_eq!(task2_updated.status, "pending");
}
#[test]
fn auto_advance_single_blocker_completes() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = auto_advance_enabled("cancelled");
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task1 = db
.create_task(
None,
"Blocker".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task2 = db
.create_task(
None,
"Blocked".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.add_dependency(&task1.id, &task2.id, "blocks", &deps_config)
.unwrap();
db.claim_task(&task1.id, &agent.id, &states_config).unwrap();
let (_, unblocked, auto_advanced, _) = db
.update_task_unified(
&task1.id,
&agent.id,
None, None,
None,
Some("completed".to_string()),
None, None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(unblocked.len(), 1);
assert_eq!(unblocked[0], task2.id);
assert_eq!(auto_advanced.len(), 1);
assert_eq!(auto_advanced[0], task2.id);
let task2_updated = db.get_task(&task2.id).unwrap().unwrap();
assert_eq!(task2_updated.status, "cancelled");
}
#[test]
fn auto_advance_multiple_blockers_waits_for_all() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = auto_advance_enabled("cancelled");
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task1 = db
.create_task(
None,
"Blocker 1".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task2 = db
.create_task(
None,
"Blocked".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task3 = db
.create_task(
None,
"Blocker 2".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.add_dependency(&task1.id, &task2.id, "blocks", &deps_config)
.unwrap();
db.add_dependency(&task3.id, &task2.id, "blocks", &deps_config)
.unwrap();
db.claim_task(&task1.id, &agent.id, &states_config).unwrap();
let (_, _, auto_advanced_1, _) = db
.update_task_unified(
&task1.id,
&agent.id,
None, None,
None,
Some("completed".to_string()),
None, None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert!(auto_advanced_1.is_empty());
let task2_status = db.get_task(&task2.id).unwrap().unwrap();
assert_eq!(task2_status.status, "pending");
db.claim_task(&task3.id, &agent.id, &states_config).unwrap();
let (_, _, auto_advanced_2, _) = db
.update_task_unified(
&task3.id,
&agent.id,
None, None,
None,
Some("completed".to_string()),
None, None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(auto_advanced_2.len(), 1);
assert_eq!(auto_advanced_2[0], task2.id);
let task2_updated = db.get_task(&task2.id).unwrap().unwrap();
assert_eq!(task2_updated.status, "cancelled");
}
#[test]
fn auto_advance_skips_non_initial_state_tasks() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = auto_advance_enabled("cancelled");
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task1 = db
.create_task(
None,
"Blocker".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task2 = db
.create_task(
None,
"Blocked".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.add_dependency(&task1.id, &task2.id, "blocks", &deps_config)
.unwrap();
db.claim_task(&task2.id, &agent.id, &states_config).unwrap();
db.claim_task(&task1.id, &agent.id, &states_config).unwrap();
let (_, _, auto_advanced, _) = db
.update_task_unified(
&task1.id,
&agent.id,
None, None,
None,
Some("completed".to_string()),
None, None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert!(auto_advanced.is_empty());
let task2_updated = db.get_task(&task2.id).unwrap().unwrap();
assert_eq!(task2_updated.status, "working"); }
#[test]
fn auto_advance_cascading_chain() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = auto_advance_enabled("cancelled");
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let task1 = db
.create_task(
None,
"Task 1".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task2 = db
.create_task(
None,
"Task 2".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task3 = db
.create_task(
None,
"Task 3".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.add_dependency(&task1.id, &task2.id, "blocks", &deps_config)
.unwrap();
db.add_dependency(&task2.id, &task3.id, "blocks", &deps_config)
.unwrap();
db.claim_task(&task1.id, &agent.id, &states_config).unwrap();
let (_, _, auto_advanced, _) = db
.update_task_unified(
&task1.id,
&agent.id,
None, None,
None,
Some("completed".to_string()),
None, None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(auto_advanced.len(), 1);
assert_eq!(auto_advanced[0], task2.id);
let task2_updated = db.get_task(&task2.id).unwrap().unwrap();
assert_eq!(task2_updated.status, "cancelled");
let task3_updated = db.get_task(&task3.id).unwrap().unwrap();
assert_eq!(task3_updated.status, "pending"); }
}
mod auto_rollup_tests {
use super::*;
fn rollup_config() -> AutoAdvanceConfig {
AutoAdvanceConfig {
enabled: false,
target_state: None,
auto_rollup: true,
}
}
#[test]
fn parent_auto_completes_when_all_children_finish() {
let db = setup_db();
let states_config = StatesConfig::default();
let deps_config = DependenciesConfig::default();
let auto_advance = rollup_config();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let parent = db
.create_task(
None,
"Parent Task".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let child1 = db
.create_task(
None,
"Child 1".to_string(),
None,
Some(parent.id.clone()),
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let child2 = db
.create_task(
None,
"Child 2".to_string(),
None,
Some(parent.id.clone()),
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&child1.id, &agent.id, &states_config)
.unwrap();
let (_, _, _, auto_completed_1) = db
.update_task_unified(
&child1.id,
&agent.id,
None,
None,
None,
Some("completed".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert!(auto_completed_1.is_empty());
let parent_check = db.get_task(&parent.id).unwrap().unwrap();
assert_eq!(parent_check.status, "pending");
db.claim_task(&child2.id, &agent.id, &states_config)
.unwrap();
let (_, _, _, auto_completed_2) = db
.update_task_unified(
&child2.id,
&agent.id,
None,
None,
None,
Some("completed".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(auto_completed_2.len(), 1);
assert_eq!(auto_completed_2[0].0, parent.id);
assert_eq!(auto_completed_2[0].1, "Parent Task");
let parent_final = db.get_task(&parent.id).unwrap().unwrap();
assert_eq!(parent_final.status, "completed");
assert!(parent_final.completed_at.is_some());
}
#[test]
fn rollup_enabled_by_default() {
let db = setup_db();
let states_config = StatesConfig::default();
let deps_config = DependenciesConfig::default();
let auto_advance = AutoAdvanceConfig::default();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let parent = db
.create_task(
None,
"Parent".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let child = db
.create_task(
None,
"Child".to_string(),
None,
Some(parent.id.clone()),
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&child.id, &agent.id, &states_config).unwrap();
let (_, _, _, auto_completed) = db
.update_task_unified(
&child.id,
&agent.id,
None,
None,
None,
Some("completed".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(auto_completed.len(), 1);
let parent_check = db.get_task(&parent.id).unwrap().unwrap();
assert_eq!(parent_check.status, "completed");
}
#[test]
fn rollup_can_be_explicitly_disabled() {
let db = setup_db();
let states_config = StatesConfig::default();
let deps_config = DependenciesConfig::default();
let auto_advance = AutoAdvanceConfig {
auto_rollup: false,
..Default::default()
};
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let parent = db
.create_task(
None,
"Parent".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let child = db
.create_task(
None,
"Child".to_string(),
None,
Some(parent.id.clone()),
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&child.id, &agent.id, &states_config).unwrap();
let (_, _, _, auto_completed) = db
.update_task_unified(
&child.id,
&agent.id,
None,
None,
None,
Some("completed".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert!(auto_completed.is_empty());
let parent_check = db.get_task(&parent.id).unwrap().unwrap();
assert_eq!(parent_check.status, "pending");
}
#[test]
fn recursive_rollup_grandparent() {
let db = setup_db();
let states_config = StatesConfig::default();
let deps_config = DependenciesConfig::default();
let auto_advance = rollup_config();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let grandparent = db
.create_task(
None,
"Grandparent".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let parent = db
.create_task(
None,
"Parent".to_string(),
None,
Some(grandparent.id.clone()),
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let child = db
.create_task(
None,
"Child".to_string(),
None,
Some(parent.id.clone()),
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&child.id, &agent.id, &states_config).unwrap();
let (_, _, _, auto_completed) = db
.update_task_unified(
&child.id,
&agent.id,
None,
None,
None,
Some("completed".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(auto_completed.len(), 2);
assert_eq!(auto_completed[0].0, parent.id);
assert_eq!(auto_completed[1].0, grandparent.id);
let parent_final = db.get_task(&parent.id).unwrap().unwrap();
assert_eq!(parent_final.status, "completed");
let gp_final = db.get_task(&grandparent.id).unwrap().unwrap();
assert_eq!(gp_final.status, "completed");
}
#[test]
fn cancelled_children_count_as_terminal() {
let db = setup_db();
let states_config = StatesConfig::default();
let deps_config = DependenciesConfig::default();
let auto_advance = rollup_config();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let parent = db
.create_task(
None,
"Parent".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let child1 = db
.create_task(
None,
"Child 1".to_string(),
None,
Some(parent.id.clone()),
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let child2 = db
.create_task(
None,
"Child 2".to_string(),
None,
Some(parent.id.clone()),
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&child1.id, &agent.id, &states_config)
.unwrap();
db.update_task_unified(
&child1.id,
&agent.id,
None,
None,
None,
Some("completed".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
let (_, _, _, auto_completed) = db
.update_task_unified(
&child2.id,
&agent.id,
None,
None,
None,
Some("cancelled".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(auto_completed.len(), 1);
let parent_final = db.get_task(&parent.id).unwrap().unwrap();
assert_eq!(parent_final.status, "completed");
}
#[test]
fn parent_already_terminal_no_rollup() {
let db = setup_db();
let states_config = StatesConfig::default();
let deps_config = DependenciesConfig::default();
let auto_advance = rollup_config();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let parent = db
.create_task(
None,
"Parent".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let child = db
.create_task(
None,
"Child".to_string(),
None,
Some(parent.id.clone()),
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&parent.id, &agent.id, &states_config)
.unwrap();
db.update_task_unified(
&parent.id,
&agent.id,
None,
None,
None,
Some("completed".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
true, &states_config,
&deps_config,
&auto_advance,
)
.unwrap();
db.claim_task(&child.id, &agent.id, &states_config).unwrap();
let (_, _, _, auto_completed) = db
.update_task_unified(
&child.id,
&agent.id,
None,
None,
None,
Some("completed".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert!(auto_completed.is_empty());
}
#[test]
fn parent_in_working_state_completes_directly() {
let db = setup_db();
let states_config = StatesConfig::default();
let deps_config = DependenciesConfig::default();
let auto_advance = rollup_config();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let parent = db
.create_task(
None,
"Parent".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let child = db
.create_task(
None,
"Child".to_string(),
None,
Some(parent.id.clone()),
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.claim_task(&parent.id, &agent.id, &states_config)
.unwrap();
let parent_check = db.get_task(&parent.id).unwrap().unwrap();
assert_eq!(parent_check.status, "working");
db.claim_task(&child.id, &agent.id, &states_config).unwrap();
let (_, _, _, auto_completed) = db
.update_task_unified(
&child.id,
&agent.id,
None,
None,
None,
Some("completed".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(auto_completed.len(), 1);
let parent_final = db.get_task(&parent.id).unwrap().unwrap();
assert_eq!(parent_final.status, "completed");
}
#[test]
fn pending_parent_auto_transitions_through_working_to_completed() {
let db = setup_db();
let states_config = StatesConfig::default();
let deps_config = DependenciesConfig::default();
let auto_advance = rollup_config();
let agent = db
.register_worker(
None,
vec![],
false,
&default_ids_config(),
None,
vec![],
Some(0),
)
.unwrap();
let parent = db
.create_task(
None,
"Rollup Parent".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let child1 = db
.create_task(
None,
"Rollup Child 1".to_string(),
None,
Some(parent.id.clone()),
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let child2 = db
.create_task(
None,
"Rollup Child 2".to_string(),
None,
Some(parent.id.clone()),
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
assert_eq!(db.get_task(&parent.id).unwrap().unwrap().status, "pending");
db.claim_task(&child1.id, &agent.id, &states_config)
.unwrap();
let (_, _, _, auto_completed_1) = db
.update_task_unified(
&child1.id,
&agent.id,
None,
None,
None,
Some("completed".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert!(auto_completed_1.is_empty());
assert_eq!(db.get_task(&parent.id).unwrap().unwrap().status, "pending");
db.claim_task(&child2.id, &agent.id, &states_config)
.unwrap();
let (_, _, _, auto_completed_2) = db
.update_task_unified(
&child2.id,
&agent.id,
None,
None,
None,
Some("completed".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
assert_eq!(auto_completed_2.len(), 1);
assert_eq!(auto_completed_2[0].0, parent.id);
let parent_final = db.get_task(&parent.id).unwrap().unwrap();
assert_eq!(parent_final.status, "completed");
assert!(parent_final.completed_at.is_some());
assert!(parent_final.started_at.is_some());
let history = db.get_task_state_history(&parent.id).unwrap();
let statuses: Vec<String> = history.iter().filter_map(|h| h.status.clone()).collect();
assert!(
statuses.iter().any(|s| s == "working"),
"should have working transition"
);
assert!(
statuses.iter().any(|s| s == "completed"),
"should have completed transition"
);
}
}
mod attachment_tests {
use super::*;
fn create_test_task(db: &Database) -> task_graph_mcp::types::Task {
db.create_task(
None,
"Attachment Test".to_string(),
None,
None,
None, None,
None,
None,
None,
None,
None,
&default_states_config(),
&default_ids_config(),
)
.unwrap()
}
#[test]
fn get_attachments_filtered_by_mime_prefix() {
let db = setup_db();
let task = create_test_task(&db);
db.add_attachment(
&task.id,
"data-json".to_string(),
String::new(),
r#"{"key": "value"}"#.to_string(),
Some("application/json".to_string()),
None,
)
.unwrap();
db.add_attachment(
&task.id,
"readme-txt".to_string(),
String::new(),
"This is a text file".to_string(),
Some("text/plain".to_string()),
None,
)
.unwrap();
db.add_attachment(
&task.id,
"notes-md".to_string(),
String::new(),
"# Notes\nSome markdown".to_string(),
Some("text/markdown".to_string()),
None,
)
.unwrap();
let json_attachments = db
.get_attachments_filtered(&task.id, None, Some("application/json"))
.unwrap();
assert_eq!(json_attachments.len(), 1);
assert_eq!(json_attachments[0].attachment_type, "data-json");
let text_attachments = db
.get_attachments_filtered(&task.id, None, Some("text/"))
.unwrap();
assert_eq!(text_attachments.len(), 2);
let types: Vec<&str> = text_attachments
.iter()
.map(|a| a.attachment_type.as_str())
.collect();
assert!(types.contains(&"readme-txt"));
assert!(types.contains(&"notes-md"));
}
#[test]
fn get_attachments_filtered_by_type_pattern() {
let db = setup_db();
let task = create_test_task(&db);
db.add_attachment(
&task.id,
"data-json".to_string(),
String::new(),
r#"{"key": "value"}"#.to_string(),
Some("application/json".to_string()),
None,
)
.unwrap();
db.add_attachment(
&task.id,
"config-json".to_string(),
String::new(),
r#"{"setting": true}"#.to_string(),
Some("application/json".to_string()),
None,
)
.unwrap();
db.add_attachment(
&task.id,
"readme-txt".to_string(),
String::new(),
"Text content".to_string(),
Some("text/plain".to_string()),
None,
)
.unwrap();
let json_types = db
.get_attachments_filtered(&task.id, Some("*-json"), None)
.unwrap();
assert_eq!(json_types.len(), 2);
let data_type = db
.get_attachments_filtered(&task.id, Some("data-json"), None)
.unwrap();
assert_eq!(data_type.len(), 1);
assert_eq!(data_type[0].attachment_type, "data-json");
}
#[test]
fn get_attachments_filtered_by_both_type_and_mime() {
let db = setup_db();
let task = create_test_task(&db);
db.add_attachment(
&task.id,
"data-json".to_string(),
String::new(),
r#"{"key": "value"}"#.to_string(),
Some("application/json".to_string()),
None,
)
.unwrap();
db.add_attachment(
&task.id,
"schema-json".to_string(),
String::new(),
r#"{"type": "object"}"#.to_string(),
Some("application/json".to_string()),
None,
)
.unwrap();
db.add_attachment(
&task.id,
"data-txt".to_string(),
String::new(),
"Plain text".to_string(),
Some("text/plain".to_string()),
None,
)
.unwrap();
let result = db
.get_attachments_filtered(&task.id, Some("data-*"), Some("application/json"))
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].attachment_type, "data-json");
}
#[test]
fn get_attachments_no_filter_returns_all() {
let db = setup_db();
let task = create_test_task(&db);
db.add_attachment(
&task.id,
"file1".to_string(),
String::new(),
"Content 1".to_string(),
Some("text/plain".to_string()),
None,
)
.unwrap();
db.add_attachment(
&task.id,
"file2".to_string(),
String::new(),
"{}".to_string(),
Some("application/json".to_string()),
None,
)
.unwrap();
let all = db.get_attachments_filtered(&task.id, None, None).unwrap();
assert_eq!(all.len(), 2);
}
}
mod rename_tests {
use super::*;
#[test]
fn rename_task_updates_all_references() {
let db = setup_db();
let sc = default_states_config();
let dc = default_deps_config();
let ic = default_ids_config();
db.create_task(
Some("task-a".to_string()),
"Task A".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
Some(vec!["tag1".to_string()]),
&sc,
&ic,
)
.unwrap();
db.create_task(
Some("task-b".to_string()),
"Task B".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&sc,
&ic,
)
.unwrap();
db.add_dependency_soft("task-a", "task-b", "blocks", &dc)
.unwrap();
db.add_attachment(
"task-a",
"note".to_string(),
"test note".to_string(),
"content".to_string(),
Some("text/plain".to_string()),
None,
)
.unwrap();
db.rename_task("task-a", "task-alpha").unwrap();
assert!(db.get_task("task-a").unwrap().is_none());
let renamed = db.get_task("task-alpha").unwrap().unwrap();
assert_eq!(renamed.title, "Task A");
assert!(renamed.tags.contains(&"tag1".to_string()));
let blockers = db.get_blockers("task-b").unwrap();
assert!(blockers.contains(&"task-alpha".to_string()));
let attachments = db.get_attachments("task-alpha").unwrap();
assert_eq!(attachments.len(), 1);
assert_eq!(attachments[0].task_id, "task-alpha");
}
#[test]
fn rename_task_fails_if_new_id_exists() {
let db = setup_db();
let sc = default_states_config();
let ic = default_ids_config();
db.create_task(
Some("task-a".to_string()),
"Task A".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&sc,
&ic,
)
.unwrap();
db.create_task(
Some("task-b".to_string()),
"Task B".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&sc,
&ic,
)
.unwrap();
let result = db.rename_task("task-a", "task-b");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("already exists"));
}
#[test]
fn rename_task_fails_if_old_id_missing() {
let db = setup_db();
let result = db.rename_task("nonexistent", "new-id");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
}
}
mod claim_prompt_delivery_tests {
use super::*;
use serde_json::json;
fn hierarchical_workflows() -> WorkflowsConfig {
let yaml = r#"
name: test-hierarchical
settings:
initial_state: pending
blocking_states: [pending, assigned, working]
states:
pending:
exits: [assigned, working, cancelled]
timed: false
assigned:
exits: [working, pending, cancelled]
timed: false
prompts:
enter: "Task assigned by coordinator. Review context before claiming."
working:
exits: [completed, failed, pending]
timed: true
prompts:
enter: "Now working. Follow the workflow guidance."
exit: "Unmark files and attach results."
completed:
exits: [pending]
timed: false
prompts:
enter: "Task completed."
failed:
exits: [pending]
timed: false
cancelled:
exits: []
timed: false
phases:
implement:
prompts:
enter: "Implement phase."
combos:
assigned+implement:
enter: "Implementation assigned. Read design spec before claiming."
working+implement:
enter: "Working on implementation."
roles:
coordinator:
tags: [coordinator, lead]
can_assign: true
worker:
tags: [worker]
role_prompts:
worker:
claiming: "After claiming, review prompts for workflow guidance."
reporting: "Use thinking() for visibility."
"#;
serde_yaml::from_str(yaml).expect("Failed to parse test workflow YAML")
}
#[test]
fn claim_from_assigned_delivers_exit_assigned_and_enter_working_prompts() {
let db = setup_db();
let workflows = hierarchical_workflows();
let states_config: StatesConfig = (&workflows).into();
let config = default_app_config();
let coordinator = db
.register_worker(
Some("coordinator-1".to_string()),
vec!["coordinator".to_string(), "lead".to_string()],
false,
&default_ids_config(),
None,
vec![],
None,
)
.unwrap();
let worker = db
.register_worker(
Some("worker-1".to_string()),
vec!["worker".to_string()],
false,
&default_ids_config(),
None,
vec![],
None,
)
.unwrap();
let task = db
.create_task(
Some("test-task-1".to_string()),
"Test prompt delivery".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
db.update_task_unified(
&task.id,
&coordinator.id,
Some(&worker.id), None,
None,
None, None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
let assigned_task = db.get_task("test-task-1").unwrap().unwrap();
assert_eq!(assigned_task.status, "assigned");
assert_eq!(assigned_task.worker_id, Some("worker-1".to_string()));
let result = task_graph_mcp::tools::claiming::claim(
&db,
&config,
&workflows,
json!({
"worker_id": "worker-1",
"task": "test-task-1"
}),
)
.unwrap();
let prompts = result
.get("prompts")
.expect("claim response should have prompts");
let prompts_arr = prompts.as_array().expect("prompts should be an array");
let all_prompts_text: String = prompts_arr
.iter()
.filter_map(|p| p.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("\n");
assert!(
all_prompts_text.contains("Now working"),
"Should include enter~working prompt. Got: {}",
all_prompts_text
);
assert_eq!(
result.get("pre_claim_status").and_then(|v| v.as_str()),
Some("assigned"),
"Response should include pre_claim_status"
);
}
#[test]
fn claim_from_assigned_with_phase_delivers_combo_prompts() {
let db = setup_db();
let workflows = hierarchical_workflows();
let states_config: StatesConfig = (&workflows).into();
let config = default_app_config();
let coordinator = db
.register_worker(
Some("coord-2".to_string()),
vec!["coordinator".to_string(), "lead".to_string()],
false,
&default_ids_config(),
None,
vec![],
None,
)
.unwrap();
let worker = db
.register_worker(
Some("worker-2".to_string()),
vec!["worker".to_string()],
false,
&default_ids_config(),
None,
vec![],
None,
)
.unwrap();
let task = db
.create_task(
Some("phase-task".to_string()),
"Phase combo test".to_string(),
None,
None,
Some("implement".to_string()), None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
db.update_task_unified(
&task.id,
&coordinator.id,
Some(&worker.id),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
let result = task_graph_mcp::tools::claiming::claim(
&db,
&config,
&workflows,
json!({
"worker_id": "worker-2",
"task": "phase-task"
}),
)
.unwrap();
let prompts = result.get("prompts").expect("should have prompts");
let prompts_arr = prompts.as_array().expect("prompts should be an array");
let all_prompts_text: String = prompts_arr
.iter()
.filter_map(|p| p.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("\n");
assert!(
all_prompts_text.contains("Working on implementation"),
"Should include working+implement combo prompt. Got: {}",
all_prompts_text
);
assert!(
all_prompts_text.contains("After claiming, review prompts"),
"Should include role claiming prompt. Got: {}",
all_prompts_text
);
assert_eq!(
result.get("pre_claim_phase").and_then(|v| v.as_str()),
Some("implement"),
"Response should include pre_claim_phase"
);
}
#[test]
fn claim_from_pending_still_delivers_working_prompts() {
let db = setup_db();
let workflows = hierarchical_workflows();
let states_config: StatesConfig = (&workflows).into();
let config = default_app_config();
let _worker = db
.register_worker(
Some("worker-3".to_string()),
vec!["worker".to_string()],
false,
&default_ids_config(),
None,
vec![],
None,
)
.unwrap();
let _task = db
.create_task(
Some("pending-task".to_string()),
"Direct claim test".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let result = task_graph_mcp::tools::claiming::claim(
&db,
&config,
&workflows,
json!({
"worker_id": "worker-3",
"task": "pending-task"
}),
)
.unwrap();
let prompts = result.get("prompts").expect("should have prompts");
let prompts_arr = prompts.as_array().expect("prompts should be an array");
let all_prompts_text: String = prompts_arr
.iter()
.filter_map(|p| p.get("text").and_then(|t| t.as_str()))
.collect::<Vec<_>>()
.join("\n");
assert!(
all_prompts_text.contains("Now working"),
"Should include enter~working prompt for pending->working. Got: {}",
all_prompts_text
);
assert_eq!(
result.get("pre_claim_status").and_then(|v| v.as_str()),
Some("pending"),
"pre_claim_status should be 'pending'"
);
}
}
mod file_contention_tests {
use super::*;
use serde_json::json;
fn setup_sibling_tasks(
db: &Database,
states_config: &StatesConfig,
) -> (String, String, String) {
let parent = db
.create_task(
Some("parent-task".to_string()),
"Parent".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
states_config,
&default_ids_config(),
)
.unwrap();
let child1 = db
.create_task(
Some("child-1".to_string()),
"Child 1".to_string(),
Some(parent.id.clone()),
None,
None,
None,
None,
None,
None,
None,
None,
states_config,
&default_ids_config(),
)
.unwrap();
let child2 = db
.create_task(
Some("child-2".to_string()),
"Child 2".to_string(),
Some(parent.id.clone()),
None,
None,
None,
None,
None,
None,
None,
None,
states_config,
&default_ids_config(),
)
.unwrap();
(parent.id, child1.id, child2.id)
}
#[test]
fn claim_detects_file_contention_with_sibling_task() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let config = default_app_config();
let workflows = WorkflowsConfig::default();
let _worker1 = db
.register_worker(
Some("worker-a".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
None,
)
.unwrap();
let _worker2 = db
.register_worker(
Some("worker-b".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
None,
)
.unwrap();
let (_parent_id, child1_id, child2_id) = setup_sibling_tasks(&db, &states_config);
db.update_task_unified(
&child1_id,
"worker-a",
None,
None,
None,
Some("working".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
db.lock_file(
"/project/src/main.rs".to_string(),
"worker-a",
Some("editing".to_string()),
Some(child1_id.clone()),
)
.unwrap();
db.lock_file(
"/project/src/lib.rs".to_string(),
"worker-a",
Some("editing".to_string()),
Some(child1_id.clone()),
)
.unwrap();
let result = task_graph_mcp::tools::claiming::claim(
&db,
&config,
&workflows,
json!({
"worker_id": "worker-b",
"task": child2_id
}),
)
.unwrap();
let contention = result
.get("file_contention")
.expect("claim response should include file_contention for sibling tasks");
let contention_arr = contention
.as_array()
.expect("file_contention should be an array");
assert_eq!(
contention_arr.len(),
2,
"Should detect 2 file contentions (sibling marks), got: {:?}",
contention_arr
);
let files: Vec<&str> = contention_arr
.iter()
.filter_map(|e| e.get("file").and_then(|v| v.as_str()))
.collect();
assert!(files.contains(&"/project/src/lib.rs"));
assert!(files.contains(&"/project/src/main.rs"));
for entry in contention_arr {
assert_eq!(
entry.get("other_worker").and_then(|v| v.as_str()),
Some("worker-a")
);
}
}
#[test]
fn claim_returns_no_contention_when_no_sibling_marks() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let config = default_app_config();
let workflows = WorkflowsConfig::default();
let _worker1 = db
.register_worker(
Some("worker-c".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
None,
)
.unwrap();
let _worker2 = db
.register_worker(
Some("worker-d".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
None,
)
.unwrap();
let (_parent_id, child1_id, child2_id) = setup_sibling_tasks(&db, &states_config);
db.update_task_unified(
&child1_id,
"worker-c",
None,
None,
None,
Some("working".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
let result = task_graph_mcp::tools::claiming::claim(
&db,
&config,
&workflows,
json!({
"worker_id": "worker-d",
"task": child2_id
}),
)
.unwrap();
assert!(
result.get("file_contention").is_none(),
"Should not have file_contention when sibling has no file marks"
);
}
#[test]
fn claim_ignores_contention_from_completed_tasks() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let config = default_app_config();
let workflows = WorkflowsConfig::default();
let _worker1 = db
.register_worker(
Some("worker-e".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
None,
)
.unwrap();
let _worker2 = db
.register_worker(
Some("worker-f".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
None,
)
.unwrap();
let (_parent_id, child1_id, child2_id) = setup_sibling_tasks(&db, &states_config);
db.update_task_unified(
&child1_id,
"worker-e",
None,
None,
None,
Some("working".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
db.lock_file(
"/project/src/shared.rs".to_string(),
"worker-e",
None,
Some(child1_id.clone()),
)
.unwrap();
db.update_task_unified(
&child1_id,
"worker-e",
None,
None,
None,
Some("completed".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
true, &states_config,
&deps_config,
&auto_advance,
)
.unwrap();
let result = task_graph_mcp::tools::claiming::claim(
&db,
&config,
&workflows,
json!({
"worker_id": "worker-f",
"task": child2_id
}),
)
.unwrap();
assert!(
result.get("file_contention").is_none(),
"Should not detect contention from completed sibling tasks"
);
}
#[test]
fn find_file_contention_db_method_with_siblings() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let _worker1 = db
.register_worker(
Some("w1".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
None,
)
.unwrap();
let _worker2 = db
.register_worker(
Some("w2".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
None,
)
.unwrap();
let (_parent_id, child1_id, child2_id) = setup_sibling_tasks(&db, &states_config);
db.update_task_unified(
&child1_id,
"w1",
None,
None,
None,
Some("working".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
db.update_task_unified(
&child2_id,
"w2",
None,
None,
None,
Some("working".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
db.lock_file(
"/shared.rs".to_string(),
"w1",
None,
Some(child1_id.clone()),
)
.unwrap();
db.lock_file(
"/unique1.rs".to_string(),
"w1",
None,
Some(child1_id.clone()),
)
.unwrap();
db.lock_file(
"/unique2.rs".to_string(),
"w2",
None,
Some(child2_id.clone()),
)
.unwrap();
let contention = db.find_file_contention(&child2_id, "w2").unwrap();
assert_eq!(
contention.len(),
2,
"Should find 2 contentions (sibling's marks): {:?}",
contention
);
let files: Vec<&str> = contention.iter().map(|(f, _, _)| f.as_str()).collect();
assert!(files.contains(&"/shared.rs"));
assert!(files.contains(&"/unique1.rs"));
let contention = db.find_file_contention(&child1_id, "w1").unwrap();
assert_eq!(
contention.len(),
1,
"Should find 1 contention from child1's perspective"
);
assert_eq!(contention[0].0, "/unique2.rs");
assert_eq!(contention[0].2, "w2");
}
#[test]
fn find_file_contention_no_parent_checks_all_active_workers() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let _worker1 = db
.register_worker(
Some("nw1".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
None,
)
.unwrap();
let _worker2 = db
.register_worker(
Some("nw2".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
None,
)
.unwrap();
let task1 = db
.create_task(
Some("ind-t1".to_string()),
"Independent 1".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task2 = db
.create_task(
Some("ind-t2".to_string()),
"Independent 2".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.update_task_unified(
&task1.id,
"nw1",
None,
None,
None,
Some("working".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
db.update_task_unified(
&task2.id,
"nw2",
None,
None,
None,
Some("working".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
db.lock_file(
"/global.rs".to_string(),
"nw1",
None,
Some("ind-t1".to_string()),
)
.unwrap();
let contention = db.find_file_contention("ind-t2", "nw2").unwrap();
assert_eq!(
contention.len(),
1,
"Should find 1 contention from all active workers: {:?}",
contention
);
assert_eq!(contention[0].0, "/global.rs");
assert_eq!(contention[0].2, "nw1");
}
#[test]
fn find_file_contention_ignores_lock_prefixed_entries() {
let db = setup_db();
let states_config = default_states_config();
let deps_config = default_deps_config();
let auto_advance = default_auto_advance();
let _worker1 = db
.register_worker(
Some("lw1".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
None,
)
.unwrap();
let _worker2 = db
.register_worker(
Some("lw2".to_string()),
vec![],
false,
&default_ids_config(),
None,
vec![],
None,
)
.unwrap();
let task1 = db
.create_task(
Some("lt1".to_string()),
"Lock Test 1".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
let task2 = db
.create_task(
Some("lt2".to_string()),
"Lock Test 2".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&default_ids_config(),
)
.unwrap();
db.update_task_unified(
&task1.id,
"lw1",
None,
None,
None,
Some("working".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
db.update_task_unified(
&task2.id,
"lw2",
None,
None,
None,
Some("working".to_string()),
None,
None,
None,
None,
None,
None,
None,
None,
false,
&states_config,
&deps_config,
&auto_advance,
)
.unwrap();
db.lock_file(
"lock:git-commit".to_string(),
"lw1",
None,
Some("lt1".to_string()),
)
.unwrap();
let contention = db.find_file_contention("lt2", "lw2").unwrap();
assert!(
contention.is_empty(),
"lock: prefixed entries should not appear in file contention: {:?}",
contention
);
}
}
mod update_prompts_param_tests {
use super::*;
use serde_json::json;
use task_graph_mcp::tools::tasks;
fn setup_worker_and_task() -> (Database, String, String) {
let db = setup_db();
let ids_config = default_ids_config();
let states_config = default_states_config();
let worker = db
.register_worker(None, vec![], false, &ids_config, None, vec![], None)
.expect("register worker");
let task = db
.create_task(
None,
"Test task".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
None,
&states_config,
&ids_config,
)
.unwrap();
(db, worker.id, task.id)
}
#[test]
fn update_prompts_all_returns_prompts() {
let (db, worker_id, task_id) = setup_worker_and_task();
let config = default_app_config();
let workflows = WorkflowsConfig::default();
let result = tasks::update(
tasks::UpdateOptions {
db: &db,
config: &config,
workflows: &workflows,
},
json!({
"worker_id": worker_id,
"task": task_id,
"status": "working",
"prompts": "all",
}),
)
.expect("update should succeed");
assert!(
result.get("prompts").is_some(),
"prompts='all' should include prompts in response"
);
let prompts = result["prompts"].as_array().unwrap();
assert!(
!prompts.is_empty(),
"prompts='all' should return at least one prompt for pending->working"
);
}
#[test]
fn update_prompts_default_returns_prompts() {
let (db, worker_id, task_id) = setup_worker_and_task();
let config = default_app_config();
let workflows = WorkflowsConfig::default();
let result = tasks::update(
tasks::UpdateOptions {
db: &db,
config: &config,
workflows: &workflows,
},
json!({
"worker_id": worker_id,
"task": task_id,
"status": "working",
}),
)
.expect("update should succeed");
assert!(
result.get("prompts").is_some(),
"default prompts mode should include prompts in response"
);
}
#[test]
fn update_prompts_none_suppresses_all_prompts() {
let (db, worker_id, task_id) = setup_worker_and_task();
let config = default_app_config();
let workflows = WorkflowsConfig::default();
let result = tasks::update(
tasks::UpdateOptions {
db: &db,
config: &config,
workflows: &workflows,
},
json!({
"worker_id": worker_id,
"task": task_id,
"status": "working",
"prompts": "none",
}),
)
.expect("update should succeed");
assert!(
result.get("prompts").is_none(),
"prompts='none' should suppress all prompts (got: {:?})",
result.get("prompts")
);
assert_eq!(result["status"], "working");
}
#[test]
fn update_prompts_caller_without_assignee_returns_prompts() {
let (db, worker_id, task_id) = setup_worker_and_task();
let config = default_app_config();
let workflows = WorkflowsConfig::default();
let result = tasks::update(
tasks::UpdateOptions {
db: &db,
config: &config,
workflows: &workflows,
},
json!({
"worker_id": worker_id,
"task": task_id,
"status": "working",
"prompts": "caller",
}),
)
.expect("update should succeed");
assert!(
result.get("prompts").is_some(),
"prompts='caller' without assignee should include prompts"
);
}
#[test]
fn update_prompts_caller_with_assignee_suppresses_prompts() {
let (db, worker_id, task_id) = setup_worker_and_task();
let config = default_app_config();
let workflows = WorkflowsConfig::default();
let ids_config = default_ids_config();
let assignee = db
.register_worker(None, vec![], false, &ids_config, None, vec![], None)
.expect("register assignee");
let result = tasks::update(
tasks::UpdateOptions {
db: &db,
config: &config,
workflows: &workflows,
},
json!({
"worker_id": worker_id,
"task": task_id,
"assignee": assignee.id,
"prompts": "caller",
}),
)
.expect("update should succeed");
assert!(
result.get("prompts").is_none(),
"prompts='caller' with assignee should suppress prompts (got: {:?})",
result.get("prompts")
);
assert_eq!(result["status"], "assigned");
}
#[test]
fn update_prompts_none_does_not_affect_other_response_fields() {
let (db, worker_id, task_id) = setup_worker_and_task();
let config = default_app_config();
let workflows = WorkflowsConfig::default();
let result = tasks::update(
tasks::UpdateOptions {
db: &db,
config: &config,
workflows: &workflows,
},
json!({
"worker_id": worker_id,
"task": task_id,
"status": "working",
"title": "Updated title",
"prompts": "none",
}),
)
.expect("update should succeed");
assert_eq!(result["status"], "working");
assert_eq!(result["title"], "Updated title");
assert_eq!(result["id"], task_id);
assert!(result.get("prompts").is_none());
}
}