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, get_string_or_array, make_tool_with_prompts};
8use crate::config::{AppConfig, Prompts, StatesConfig};
9use crate::db::Database;
10use crate::error::ToolError;
11use crate::prompts::{AttributedPrompt, 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            "files": {
34                "oneOf": [
35                    { "type": "string" },
36                    { "type": "array", "items": { "type": "string" } }
37                ],
38                "description": "File paths to mark as being worked on (auto-marks with task ID for cleanup on completion)"
39            }
40        }),
41        vec!["worker_id", "task"],
42        prompts,
43    )]
44}
45
46pub fn claim(
47    db: &Database,
48    config: &AppConfig,
49    workflows: &crate::config::workflows::WorkflowsConfig,
50    args: Value,
51) -> Result<Value> {
52    // Derive states from the per-worker workflow so overlay-added states are recognized
53    let states_config_owned: StatesConfig = workflows.into();
54    let states_config = &states_config_owned;
55    let phases_config = &config.phases;
56    let deps_config = &config.deps;
57    let auto_advance = &config.auto_advance;
58    let worker_id =
59        get_string(&args, "worker_id").ok_or_else(|| ToolError::missing_field("worker_id"))?;
60    let task_id = get_string(&args, "task").ok_or_else(|| ToolError::missing_field("task"))?;
61    let force = get_bool(&args, "force").unwrap_or(false);
62
63    // Capture the task's pre-claim status so we can deliver the correct transition prompts.
64    // When a coordinator assigns a task (pending -> assigned), the assigned-state prompts
65    // go to the coordinator. The worker never sees them. By recording the pre-claim status
66    // here, we can include assigned->working transition prompts in the claim response.
67    let pre_claim_status = db.get_task(&task_id)?.map(|t| (t.status, t.phase));
68
69    // Find the first timed state to use for claiming
70    let claim_status = states_config
71        .definitions
72        .iter()
73        .find(|(_, def)| def.timed)
74        .map(|(name, _)| name.clone())
75        .unwrap_or_else(|| "working".to_string());
76
77    // Use unified update which handles claiming when transitioning to timed state
78    // Claim transitions TO a blocking state, so unblocked/auto_advanced will be empty
79    let (task, _unblocked, _auto_advanced, _auto_completed) = match db.update_task_unified(
80        &task_id,
81        &worker_id,
82        None,               // assignee (not assigning to another agent)
83        None,               // title
84        None,               // description
85        Some(claim_status), // status - first timed state
86        None,               // phase
87        None,               // priority
88        None,               // points
89        None,               // tags
90        None,               // needed_tags
91        None,               // wanted_tags
92        None,               // time_estimate_ms
93        None,               // reason
94        force,
95        states_config,
96        deps_config,
97        auto_advance,
98    ) {
99        Ok(result) => result,
100        Err(e) => {
101            // Check if this is a dependency-blocked error and enrich with structured info
102            let err_msg = e.to_string();
103            if err_msg.contains("unsatisfied dependencies") {
104                // Query the actual blockers to provide structured info
105                let blockers = db
106                    .get_start_blockers(&task_id, deps_config)
107                    .unwrap_or_default();
108                if !blockers.is_empty() {
109                    return Err(ToolError::deps_not_satisfied(&blockers).into());
110                }
111            }
112            return Err(e);
113        }
114    };
115
116    // Auto-mark files if provided
117    let files_marked: Vec<String> = if let Some(file_paths) = get_string_or_array(&args, "files") {
118        let mut marked = Vec::new();
119        for path in file_paths {
120            let normalized = super::files::normalize_file_path(&path);
121            // Advisory mark with task_id for auto-cleanup on task completion
122            let _warning = db.lock_file(
123                normalized.clone(),
124                &worker_id,
125                None,                  // no reason
126                Some(task_id.clone()), // associate with task
127            )?;
128            marked.push(normalized);
129        }
130        marked
131    } else {
132        Vec::new()
133    };
134
135    // Pre-fetch worker info for context-sensitive prompts (must outlive ctx)
136    let worker_info = db.get_worker(&worker_id).ok().flatten();
137    let worker_role = worker_info
138        .as_ref()
139        .map(|w| workflows.match_role(&w.tags))
140        .unwrap_or(None);
141
142    // Get transition prompts for claiming (with context-sensitive template expansion + attribution).
143    //
144    // Use the task's actual pre-claim status (e.g., "assigned") as the from-state,
145    // not the worker's last_status. This ensures the worker receives the full set of
146    // transition prompts including any overlay contributions for the assigned->working
147    // transition. Previously, prompts were based on the worker's last_status which
148    // might be unrelated (e.g., "completed" from a prior task or None if just connected).
149    let mut transition_prompt_list: Vec<AttributedPrompt> = {
150        // Still update the worker's tracked state for consistency
151        let _ = db.update_worker_state(
152            &worker_id,
153            Some(&task.status),
154            task.phase.as_deref(),
155            Some(&task.id),
156        );
157
158        // Use the task's pre-claim status for prompt computation
159        let (from_status, from_phase) = match &pre_claim_status {
160            Some((status, phase)) => (status.as_str(), phase.as_deref()),
161            None => ("", None),
162        };
163
164        // Create context with task and agent info for rich template expansion
165        let mut ctx = PromptContext::new(
166            &task.status,
167            task.phase.as_deref(),
168            states_config,
169            phases_config,
170        )
171        .with_task(&task.id, &task.title, task.priority, &task.tags);
172
173        // Add hierarchy level context from level:* tags
174        let task_level_str: Option<String> = task
175            .tags
176            .iter()
177            .find(|t| t.starts_with("level:"))
178            .map(|t| t.strip_prefix("level:").unwrap_or(t).to_string());
179        let child_count = db.get_children_ids(&task.id).ok().map(|ids| ids.len());
180        let task_level_ref = task_level_str.as_deref();
181        ctx = ctx.with_level(task_level_ref, child_count);
182
183        // Add agent context if worker info is available
184        if let Some(ref worker) = worker_info {
185            ctx = ctx.with_agent(&worker_id, worker_role.as_deref(), &worker.tags);
186        }
187
188        crate::prompts::get_transition_prompts_attributed(
189            from_status,
190            from_phase,
191            &task.status,
192            task.phase.as_deref(),
193            workflows,
194            &ctx,
195        )
196    };
197
198    let mut response = json!({
199        "success": true,
200        "task": {
201            "id": &task.id,
202            "title": task.title,
203            "status": task.status,
204            "worker_id": task.worker_id,
205            "claimed_at": task.claimed_at.map(crate::types::ms_to_iso)
206        }
207    });
208
209    // Include the pre-claim status so the worker knows the task's prior state
210    // (e.g., "assigned" indicates it was push-assigned by a coordinator)
211    if let Some((pre_status, pre_phase)) = &pre_claim_status
212        && let Value::Object(ref mut map) = response
213    {
214        map.insert("pre_claim_status".to_string(), json!(pre_status));
215        if let Some(phase) = pre_phase {
216            map.insert("pre_claim_phase".to_string(), json!(phase));
217        }
218    }
219
220    // Add role-specific prompts: both "claiming" guidance and "reporting" guidance
221    // This gives the agent full context on how to work and communicate from the start
222    if let Some(ref role_name) = worker_role {
223        if let Some(claiming_prompt) = workflows.get_role_prompt(role_name, "claiming") {
224            transition_prompt_list.push(AttributedPrompt {
225                text: claiming_prompt.to_string(),
226                source: format!("role:{}", role_name),
227            });
228        }
229        // Also deliver the "reporting" prompt so the agent knows how to communicate
230        // progress from the moment they start working
231        if let Some(reporting_prompt) = workflows.get_role_prompt(role_name, "reporting") {
232            transition_prompt_list.push(AttributedPrompt {
233                text: reporting_prompt.to_string(),
234                source: format!("role:{}", role_name),
235            });
236        }
237    }
238
239    // Check for file contention: warn if files marked for this task overlap
240    // with files marked by other currently-active tasks/workers.
241    // This is advisory -- it does not block the claim.
242    let file_contention = db.find_file_contention(&task.id, &worker_id);
243
244    if let Value::Object(ref mut map) = response {
245        // Add files_marked if any files were auto-marked
246        if !files_marked.is_empty() {
247            map.insert("files_marked".to_string(), json!(files_marked));
248        }
249
250        // Add prompts if any (with source attribution)
251        if !transition_prompt_list.is_empty() {
252            let prompt_objects: Vec<Value> = transition_prompt_list
253                .iter()
254                .map(|p| json!({"text": p.text, "source": p.source}))
255                .collect();
256            map.insert("prompts".to_string(), json!(prompt_objects));
257        }
258
259        // Include relevant advisory hints based on task tags, phase, and worker role
260        let advisory_hints = super::advisories::relevant_advisory_topics(
261            workflows,
262            &task.tags,
263            task.phase.as_deref(),
264            worker_role.as_deref(),
265        );
266        if !advisory_hints.is_empty() {
267            map.insert("advisory_hints".to_string(), json!(advisory_hints));
268        }
269
270        // Include file contention warnings if any overlapping marks found
271        if let Ok(ref contentions) = file_contention
272            && !contentions.is_empty()
273        {
274            let contention_entries: Vec<Value> = contentions
275                .iter()
276                .map(|(file_path, other_task_id, other_worker_id)| {
277                    json!({
278                        "file": file_path,
279                        "other_task": other_task_id,
280                        "other_worker": other_worker_id
281                    })
282                })
283                .collect();
284            map.insert("file_contention".to_string(), json!(contention_entries));
285        }
286    }
287
288    Ok(response)
289}