Skip to main content

task_graph_mcp/tools/
claiming.rs

1//! Task claiming tools.
2//!
3//! The `claim` tool is a convenience wrapper around `update` that transitions
4//! a task to the first timed state. For releasing tasks, use `update` with
5//! a non-timed state (ownership clears automatically).
6
7use super::{get_bool, get_string, make_tool_with_prompts};
8use crate::config::{AppConfig, Prompts, StatesConfig};
9use crate::db::Database;
10use crate::error::ToolError;
11use crate::prompts::PromptContext;
12use anyhow::Result;
13use rmcp::model::Tool;
14use serde_json::{Value, json};
15
16pub fn get_tools(prompts: &Prompts, _states_config: &StatesConfig) -> Vec<Tool> {
17    vec![make_tool_with_prompts(
18        "claim",
19        "Commit to working on a task (like adding to a changelist). Fails if: already claimed, deps unsatisfied, or worker lacks required tags. Sets status to timed (working) status.",
20        json!({
21            "worker_id": {
22                "type": "string",
23                "description": "Worker ID claiming the task"
24            },
25            "task": {
26                "type": "string",
27                "description": "Task ID to claim"
28            },
29            "force": {
30                "type": "boolean",
31                "description": "Force claim even if owned by another agent (default: false)"
32            }
33        }),
34        vec!["worker_id", "task"],
35        prompts,
36    )]
37}
38
39pub fn claim(
40    db: &Database,
41    config: &AppConfig,
42    workflows: &crate::config::workflows::WorkflowsConfig,
43    args: Value,
44) -> Result<Value> {
45    let states_config = &config.states;
46    let phases_config = &config.phases;
47    let deps_config = &config.deps;
48    let auto_advance = &config.auto_advance;
49    let worker_id =
50        get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
51    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
52    let force = get_bool(&args, "force").unwrap_or(false);
53
54    // Find the first timed state to use for claiming
55    let claim_status = states_config
56        .definitions
57        .iter()
58        .find(|(_, def)| def.timed)
59        .map(|(name, _)| name.clone())
60        .unwrap_or_else(|| "working".to_string());
61
62    // Use unified update which handles claiming when transitioning to timed state
63    // Claim transitions TO a blocking state, so unblocked/auto_advanced will be empty
64    let (task, _unblocked, _auto_advanced) = match db.update_task_unified(
65        &task_id,
66        &worker_id,
67        None,               // assignee (not assigning to another agent)
68        None,               // title
69        None,               // description
70        Some(claim_status), // status - first timed state
71        None,               // phase
72        None,               // priority
73        None,               // points
74        None,               // tags
75        None,               // needed_tags
76        None,               // wanted_tags
77        None,               // time_estimate_ms
78        None,               // reason
79        force,
80        states_config,
81        deps_config,
82        auto_advance,
83    ) {
84        Ok(result) => result,
85        Err(e) => {
86            // Check if this is a dependency-blocked error and enrich with structured info
87            let err_msg = e.to_string();
88            if err_msg.contains("unsatisfied dependencies") {
89                // Query the actual blockers to provide structured info
90                let blockers = db
91                    .get_start_blockers(&task_id, deps_config)
92                    .unwrap_or_default();
93                if !blockers.is_empty() {
94                    return Err(ToolError::deps_not_satisfied(&blockers).into());
95                }
96            }
97            return Err(e);
98        }
99    };
100
101    // Pre-fetch worker info for context-sensitive prompts (must outlive ctx)
102    let worker_info = db.get_worker(&worker_id).ok().flatten();
103    let worker_role = worker_info
104        .as_ref()
105        .map(|w| workflows.match_role(&w.tags))
106        .unwrap_or(None);
107
108    // Get transition prompts for claiming (with context-sensitive template expansion)
109    let mut transition_prompt_list: Vec<String> = {
110        match db.update_worker_state(&worker_id, Some(&task.status), task.phase.as_deref()) {
111            Ok((old_status, old_phase)) => {
112                // Create context with task and agent info for rich template expansion
113                let mut ctx = PromptContext::new(
114                    &task.status,
115                    task.phase.as_deref(),
116                    states_config,
117                    phases_config,
118                )
119                .with_task(&task.id, &task.title, task.priority, &task.tags);
120
121                // Add agent context if worker info is available
122                if let Some(ref worker) = worker_info {
123                    ctx = ctx.with_agent(&worker_id, worker_role.as_deref(), &worker.tags);
124                }
125
126                crate::prompts::get_transition_prompts_with_context(
127                    old_status.as_deref().unwrap_or(""),
128                    old_phase.as_deref(),
129                    &task.status,
130                    task.phase.as_deref(),
131                    workflows,
132                    &ctx,
133                )
134            }
135            Err(_) => vec![],
136        }
137    };
138
139    let mut response = json!({
140        "success": true,
141        "task": {
142            "id": &task.id,
143            "title": task.title,
144            "status": task.status,
145            "worker_id": task.worker_id,
146            "claimed_at": task.claimed_at
147        }
148    });
149
150    // Add role-specific prompts: both "claiming" guidance and "reporting" guidance
151    // This gives the agent full context on how to work and communicate from the start
152    if let Some(ref role_name) = worker_role {
153        if let Some(claiming_prompt) = workflows.get_role_prompt(role_name, "claiming") {
154            transition_prompt_list.push(claiming_prompt.to_string());
155        }
156        // Also deliver the "reporting" prompt so the agent knows how to communicate
157        // progress from the moment they start working
158        if let Some(reporting_prompt) = workflows.get_role_prompt(role_name, "reporting") {
159            transition_prompt_list.push(reporting_prompt.to_string());
160        }
161    }
162
163    // Add prompts if any
164    if !transition_prompt_list.is_empty()
165        && let Value::Object(ref mut map) = response
166    {
167        map.insert("prompts".to_string(), json!(transition_prompt_list));
168    }
169
170    Ok(response)
171}