Skip to main content

mana_core/ops/
claim.rs

1use std::path::{Path, PathBuf};
2use std::process::Command as ShellCommand;
3
4use anyhow::{anyhow, Context, Result};
5use chrono::Utc;
6
7use crate::config::resolve_identity;
8use crate::discovery::find_unit_file;
9use crate::index::Index;
10use crate::unit::{AttemptOutcome, AttemptRecord, Status, Unit};
11
12/// Parameters for claiming a unit.
13pub struct ClaimParams {
14    /// Who is claiming the unit. If None, resolved from config/git/env.
15    pub by: Option<String>,
16    /// Skip verify-on-claim check.
17    pub force: bool,
18}
19
20/// Result of successfully claiming a unit.
21#[derive(Debug)]
22pub struct ClaimResult {
23    pub unit: Unit,
24    pub path: PathBuf,
25    /// The resolved claimer identity.
26    pub claimer: String,
27    /// Whether the unit had no verify command (a GOAL, not a SPEC).
28    pub is_goal: bool,
29}
30
31/// Result of releasing a claim on a unit.
32pub struct ReleaseResult {
33    pub unit: Unit,
34    pub path: PathBuf,
35}
36
37/// Try to get the current git HEAD SHA. Returns None if not in a git repo.
38fn git_head_sha(working_dir: &Path) -> Option<String> {
39    ShellCommand::new("git")
40        .args(["rev-parse", "HEAD"])
41        .current_dir(working_dir)
42        .output()
43        .ok()
44        .filter(|o| o.status.success())
45        .and_then(|o| String::from_utf8(o.stdout).ok())
46        .map(|s| s.trim().to_string())
47        .filter(|s| !s.is_empty())
48}
49
50/// Run a verify command and return whether it passed (exit 0).
51fn run_verify_check(verify_cmd: &str, project_root: &Path) -> Result<bool> {
52    let output = ShellCommand::new("sh")
53        .args(["-c", verify_cmd])
54        .current_dir(project_root)
55        .stdout(std::process::Stdio::null())
56        .stderr(std::process::Stdio::null())
57        .status()
58        .with_context(|| format!("Failed to execute verify command: {}", verify_cmd))?;
59
60    Ok(output.success())
61}
62
63/// Claim a unit for work.
64///
65/// Sets status to InProgress, records who claimed it and when.
66/// The unit must be in Open status to be claimed.
67///
68/// If the unit has a verify command and `force` is false, the verify command
69/// is run first. If it already passes, the claim is rejected (nothing to do).
70/// If it fails, the claim is granted with `fail_first: true` and the current
71/// git HEAD SHA is stored as `checkpoint`.
72pub fn claim(mana_dir: &Path, id: &str, params: ClaimParams) -> Result<ClaimResult> {
73    let unit_path = find_unit_file(mana_dir, id).map_err(|_| anyhow!("Unit not found: {}", id))?;
74    let mut unit =
75        Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", id))?;
76
77    if unit.status != Status::Open {
78        return Err(anyhow!(
79            "Unit {} is {} -- only open units can be claimed",
80            id,
81            unit.status
82        ));
83    }
84
85    let has_verify = unit.verify.as_ref().is_some_and(|v| !v.trim().is_empty());
86    let is_goal = !has_verify;
87
88    // Verify-on-claim: run verify before granting claim (TDD enforcement)
89    // Skip when fail_first is false (unit created with -p / pass-ok)
90    if has_verify && !params.force && unit.fail_first {
91        let project_root = mana_dir
92            .parent()
93            .ok_or_else(|| anyhow!("Cannot determine project root from units dir"))?;
94        let verify_cmd = unit.verify.as_ref().unwrap();
95
96        let passed = run_verify_check(verify_cmd, project_root)?;
97
98        if passed {
99            return Err(anyhow!(
100                "Cannot claim unit {}: verify already passes\n\n\
101                 The verify command succeeded before any work was done.\n\
102                 This means either the test is bogus or the work is already complete.\n\n\
103                 Use --force to override.",
104                id
105            ));
106        }
107
108        // Verify failed — good, this proves the test is meaningful
109        unit.fail_first = true;
110        unit.checkpoint = git_head_sha(project_root);
111    }
112
113    // Resolve identity: explicit --by > resolved identity > "anonymous"
114    let resolved_by = params.by.or_else(|| resolve_identity(mana_dir));
115    let claimer = resolved_by
116        .clone()
117        .unwrap_or_else(|| "anonymous".to_string());
118
119    let now = Utc::now();
120    unit.status = Status::InProgress;
121    unit.claimed_by = resolved_by.clone();
122    unit.claimed_at = Some(now);
123    unit.updated_at = now;
124
125    // Start a new attempt in the attempt log (for memory system tracking)
126    let attempt_num = unit.attempt_log.len() as u32 + 1;
127    unit.attempt_log.push(AttemptRecord {
128        num: attempt_num,
129        outcome: AttemptOutcome::Abandoned, // default until close/release updates it
130        notes: None,
131        agent: resolved_by,
132        started_at: Some(now),
133        finished_at: None,
134    });
135
136    unit.to_file(&unit_path)
137        .with_context(|| format!("Failed to save unit: {}", id))?;
138
139    // Rebuild index
140    let index = Index::build(mana_dir).with_context(|| "Failed to rebuild index")?;
141    index
142        .save(mana_dir)
143        .with_context(|| "Failed to save index")?;
144
145    Ok(ClaimResult {
146        unit,
147        path: unit_path,
148        claimer,
149        is_goal,
150    })
151}
152
153/// Release a claim on a unit.
154///
155/// Clears claimed_by/claimed_at and sets status back to Open.
156/// Marks the current attempt as abandoned.
157pub fn release(mana_dir: &Path, id: &str) -> Result<ReleaseResult> {
158    let unit_path = find_unit_file(mana_dir, id).map_err(|_| anyhow!("Unit not found: {}", id))?;
159    let mut unit =
160        Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", id))?;
161
162    let now = Utc::now();
163
164    // Finalize the current attempt as abandoned (if one is in progress)
165    if let Some(attempt) = unit.attempt_log.last_mut() {
166        if attempt.finished_at.is_none() {
167            attempt.outcome = AttemptOutcome::Abandoned;
168            attempt.finished_at = Some(now);
169        }
170    }
171
172    unit.claimed_by = None;
173    unit.claimed_at = None;
174    unit.status = Status::Open;
175    unit.updated_at = now;
176
177    unit.to_file(&unit_path)
178        .with_context(|| format!("Failed to save unit: {}", id))?;
179
180    // Rebuild index
181    let index = Index::build(mana_dir).with_context(|| "Failed to rebuild index")?;
182    index
183        .save(mana_dir)
184        .with_context(|| "Failed to save index")?;
185
186    Ok(ReleaseResult {
187        unit,
188        path: unit_path,
189    })
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::config::Config;
196    use crate::ops::create::{self, tests::minimal_params};
197    use std::fs;
198    use tempfile::TempDir;
199
200    fn setup() -> (TempDir, PathBuf) {
201        let dir = TempDir::new().unwrap();
202        let bd = dir.path().join(".mana");
203        fs::create_dir(&bd).unwrap();
204        Config {
205            project: "test".to_string(),
206            next_id: 1,
207            auto_close_parent: true,
208            run: None,
209            plan: None,
210            max_loops: 10,
211            max_concurrent: 4,
212            poll_interval: 30,
213            extends: vec![],
214            rules_file: None,
215            file_locking: false,
216            worktree: false,
217            on_close: None,
218            on_fail: None,
219            post_plan: None,
220            verify_timeout: None,
221            review: None,
222            user: None,
223            user_email: None,
224            auto_commit: false,
225            commit_template: None,
226            research: None,
227            run_model: None,
228            plan_model: None,
229            review_model: None,
230            research_model: None,
231            batch_verify: false,
232            memory_reserve_mb: 0,
233            notify: None,
234        }
235        .save(&bd)
236        .unwrap();
237        (dir, bd)
238    }
239
240    fn force_params(by: Option<&str>) -> ClaimParams {
241        ClaimParams {
242            by: by.map(String::from),
243            force: true,
244        }
245    }
246
247    fn strict_params(by: Option<&str>) -> ClaimParams {
248        ClaimParams {
249            by: by.map(String::from),
250            force: false,
251        }
252    }
253
254    #[test]
255    fn claim_open_unit() {
256        let (_dir, bd) = setup();
257        create::create(&bd, minimal_params("Task")).unwrap();
258
259        let result = claim(&bd, "1", force_params(Some("alice"))).unwrap();
260        assert_eq!(result.unit.status, Status::InProgress);
261        assert_eq!(result.unit.claimed_by, Some("alice".to_string()));
262        assert!(result.unit.claimed_at.is_some());
263        assert_eq!(result.claimer, "alice");
264    }
265
266    #[test]
267    fn claim_without_by() {
268        let (_dir, bd) = setup();
269        create::create(&bd, minimal_params("Task")).unwrap();
270
271        let result = claim(&bd, "1", force_params(None)).unwrap();
272        assert_eq!(result.unit.status, Status::InProgress);
273        assert!(result.unit.claimed_at.is_some());
274    }
275
276    #[test]
277    fn claim_non_open_unit_fails() {
278        let (_dir, bd) = setup();
279        create::create(&bd, minimal_params("Task")).unwrap();
280        let bp = find_unit_file(&bd, "1").unwrap();
281        let mut unit = Unit::from_file(&bp).unwrap();
282        unit.status = Status::InProgress;
283        unit.to_file(&bp).unwrap();
284
285        assert!(claim(&bd, "1", force_params(Some("bob"))).is_err());
286    }
287
288    #[test]
289    fn claim_closed_unit_fails() {
290        let (_dir, bd) = setup();
291        create::create(&bd, minimal_params("Task")).unwrap();
292        let bp = find_unit_file(&bd, "1").unwrap();
293        let mut unit = Unit::from_file(&bp).unwrap();
294        unit.status = Status::Closed;
295        unit.to_file(&bp).unwrap();
296
297        assert!(claim(&bd, "1", force_params(Some("bob"))).is_err());
298    }
299
300    #[test]
301    fn claim_nonexistent_unit_fails() {
302        let (_dir, bd) = setup();
303        assert!(claim(&bd, "99", force_params(Some("alice"))).is_err());
304    }
305
306    #[test]
307    fn release_claimed_unit() {
308        let (_dir, bd) = setup();
309        create::create(&bd, minimal_params("Task")).unwrap();
310        // First claim it
311        claim(&bd, "1", force_params(Some("alice"))).unwrap();
312
313        let result = release(&bd, "1").unwrap();
314        assert_eq!(result.unit.status, Status::Open);
315        assert_eq!(result.unit.claimed_by, None);
316        assert_eq!(result.unit.claimed_at, None);
317    }
318
319    #[test]
320    fn release_nonexistent_unit_fails() {
321        let (_dir, bd) = setup();
322        assert!(release(&bd, "99").is_err());
323    }
324
325    #[test]
326    fn claim_rebuilds_index() {
327        let (_dir, bd) = setup();
328        create::create(&bd, minimal_params("Task")).unwrap();
329
330        claim(&bd, "1", force_params(Some("alice"))).unwrap();
331
332        let index = Index::load(&bd).unwrap();
333        assert_eq!(index.units.len(), 1);
334        assert_eq!(index.units[0].status, Status::InProgress);
335    }
336
337    #[test]
338    fn release_rebuilds_index() {
339        let (_dir, bd) = setup();
340        create::create(&bd, minimal_params("Task")).unwrap();
341        claim(&bd, "1", force_params(Some("alice"))).unwrap();
342
343        release(&bd, "1").unwrap();
344
345        let index = Index::load(&bd).unwrap();
346        assert_eq!(index.units.len(), 1);
347        assert_eq!(index.units[0].status, Status::Open);
348    }
349
350    #[test]
351    fn claim_unit_without_verify_is_goal() {
352        let (_dir, bd) = setup();
353        create::create(&bd, minimal_params("Task")).unwrap();
354
355        let result = claim(&bd, "1", force_params(Some("alice"))).unwrap();
356        assert!(result.is_goal);
357    }
358
359    #[test]
360    fn claim_unit_with_verify_is_not_goal() {
361        let (_dir, bd) = setup();
362        let mut params = minimal_params("Task");
363        params.verify = Some("cargo test".to_string());
364        create::create(&bd, params).unwrap();
365
366        let result = claim(&bd, "1", force_params(Some("alice"))).unwrap();
367        assert!(!result.is_goal);
368    }
369
370    #[test]
371    fn claim_unit_with_empty_verify_is_goal() {
372        let (_dir, bd) = setup();
373        let mut params = minimal_params("Task");
374        params.verify = Some("   ".to_string());
375        params.force = true;
376        create::create(&bd, params).unwrap();
377
378        let result = claim(&bd, "1", force_params(Some("alice"))).unwrap();
379        assert!(result.is_goal);
380    }
381
382    #[test]
383    fn verify_on_claim_passing_verify_rejected() {
384        let (_dir, bd) = setup();
385        let mut params = minimal_params("Already done");
386        params.verify = Some("grep -q 'project: test' .mana/config.yaml".to_string());
387        params.fail_first = true;
388        create::create(&bd, params).unwrap();
389
390        let result = claim(&bd, "1", strict_params(Some("alice")));
391        assert!(result.is_err());
392        let err_msg = result.unwrap_err().to_string();
393        assert!(err_msg.contains("verify already passes"));
394
395        // Unit should still be open
396        let bp = find_unit_file(&bd, "1").unwrap();
397        let unit = Unit::from_file(&bp).unwrap();
398        assert_eq!(unit.status, Status::Open);
399    }
400
401    #[test]
402    fn verify_on_claim_failing_verify_succeeds() {
403        let (_dir, bd) = setup();
404        let mut params = minimal_params("Real work");
405        params.verify = Some("false".to_string());
406        params.fail_first = true;
407        create::create(&bd, params).unwrap();
408
409        let result = claim(&bd, "1", strict_params(Some("alice"))).unwrap();
410        assert_eq!(result.unit.status, Status::InProgress);
411        assert!(result.unit.fail_first);
412    }
413
414    #[test]
415    fn verify_on_claim_force_overrides() {
416        let (_dir, bd) = setup();
417        let mut params = minimal_params("Force claim");
418        params.verify = Some("grep -q 'project: test' .mana/config.yaml".to_string());
419        create::create(&bd, params).unwrap();
420
421        let result = claim(&bd, "1", force_params(Some("alice"))).unwrap();
422        assert_eq!(result.unit.status, Status::InProgress);
423    }
424
425    #[test]
426    fn verify_on_claim_checkpoint_sha_stored() {
427        let (_dir, bd) = setup();
428        let mut params = minimal_params("Checkpoint");
429        params.verify = Some("false".to_string());
430        params.fail_first = true;
431        create::create(&bd, params).unwrap();
432
433        // Initialize a git repo so we get a real SHA
434        let project_root = bd.parent().unwrap();
435        ShellCommand::new("git")
436            .args(["init"])
437            .current_dir(project_root)
438            .output()
439            .unwrap();
440        ShellCommand::new("git")
441            .args(["commit", "-m", "init", "--allow-empty"])
442            .current_dir(project_root)
443            .env("GIT_AUTHOR_NAME", "test")
444            .env("GIT_AUTHOR_EMAIL", "test@test.com")
445            .env("GIT_COMMITTER_NAME", "test")
446            .env("GIT_COMMITTER_EMAIL", "test@test.com")
447            .output()
448            .unwrap();
449
450        let result = claim(&bd, "1", strict_params(Some("alice"))).unwrap();
451        assert!(result.unit.checkpoint.is_some());
452        let sha = result.unit.checkpoint.unwrap();
453        assert_eq!(sha.len(), 40);
454        assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
455    }
456
457    #[test]
458    fn claim_starts_attempt() {
459        let (_dir, bd) = setup();
460        create::create(&bd, minimal_params("Task")).unwrap();
461
462        let result = claim(&bd, "1", force_params(Some("agent-1"))).unwrap();
463        assert_eq!(result.unit.attempt_log.len(), 1);
464        assert_eq!(result.unit.attempt_log[0].num, 1);
465        assert_eq!(
466            result.unit.attempt_log[0].agent,
467            Some("agent-1".to_string())
468        );
469        assert!(result.unit.attempt_log[0].started_at.is_some());
470        assert!(result.unit.attempt_log[0].finished_at.is_none());
471    }
472
473    #[test]
474    fn release_marks_attempt_abandoned() {
475        let (_dir, bd) = setup();
476        create::create(&bd, minimal_params("Task")).unwrap();
477        claim(&bd, "1", force_params(Some("agent-1"))).unwrap();
478
479        let result = release(&bd, "1").unwrap();
480        assert_eq!(result.unit.attempt_log.len(), 1);
481        assert_eq!(
482            result.unit.attempt_log[0].outcome,
483            AttemptOutcome::Abandoned
484        );
485        assert!(result.unit.attempt_log[0].finished_at.is_some());
486    }
487
488    #[test]
489    fn multiple_claims_accumulate_attempts() {
490        let (_dir, bd) = setup();
491        create::create(&bd, minimal_params("Task")).unwrap();
492
493        claim(&bd, "1", force_params(Some("agent-1"))).unwrap();
494        release(&bd, "1").unwrap();
495        let result = claim(&bd, "1", force_params(Some("agent-2"))).unwrap();
496
497        assert_eq!(result.unit.attempt_log.len(), 2);
498        assert_eq!(result.unit.attempt_log[0].num, 1);
499        assert_eq!(
500            result.unit.attempt_log[0].outcome,
501            AttemptOutcome::Abandoned
502        );
503        assert!(result.unit.attempt_log[0].finished_at.is_some());
504        assert_eq!(result.unit.attempt_log[1].num, 2);
505        assert_eq!(
506            result.unit.attempt_log[1].agent,
507            Some("agent-2".to_string())
508        );
509        assert!(result.unit.attempt_log[1].finished_at.is_none());
510    }
511
512    // -----------------------------------------------------------------------
513    // Timeout / stuck-in-progress recovery tests
514    // -----------------------------------------------------------------------
515
516    /// When an agent times out, mana run calls release() to reset the unit back
517    /// to Open so the next dispatch can claim it again without manual intervention.
518    #[test]
519    fn release_resets_timed_out_in_progress_unit_to_open() {
520        let (_dir, bd) = setup();
521        create::create(&bd, minimal_params("Task")).unwrap();
522
523        // Simulate agent claiming and then timing out (unit stuck in_progress)
524        claim(&bd, "1", force_params(Some("agent-1"))).unwrap();
525        // Verify unit is now in_progress (as it would be after a timeout)
526        let bp = find_unit_file(&bd, "1").unwrap();
527        let in_progress = Unit::from_file(&bp).unwrap();
528        assert_eq!(in_progress.status, Status::InProgress);
529
530        // mana run calls release() after a failed/timed-out agent
531        let result = release(&bd, "1").unwrap();
532
533        // Unit must be Open so the next mana run can claim it
534        assert_eq!(result.unit.status, Status::Open);
535        assert_eq!(result.unit.claimed_by, None);
536        assert_eq!(result.unit.claimed_at, None);
537
538        // Subsequent claim must succeed (the core fix: no manual intervention needed)
539        let second = claim(&bd, "1", force_params(Some("agent-2"))).unwrap();
540        assert_eq!(second.unit.status, Status::InProgress);
541        assert_eq!(second.unit.claimed_by, Some("agent-2".to_string()));
542    }
543
544    /// Attempting to claim a unit that is still in_progress (e.g. if release was
545    /// never called) must fail with a descriptive error.
546    #[test]
547    fn claim_stuck_in_progress_without_release_fails() {
548        let (_dir, bd) = setup();
549        create::create(&bd, minimal_params("Task")).unwrap();
550
551        // First agent claims the unit
552        claim(&bd, "1", force_params(Some("agent-1"))).unwrap();
553
554        // Second agent tries to claim without release — must fail
555        let result = claim(&bd, "1", force_params(Some("agent-2")));
556        assert!(result.is_err());
557        let err = result.unwrap_err().to_string();
558        assert!(
559            err.contains("in_progress") || err.contains("InProgress") || err.contains("only open"),
560            "Error should explain the unit is not open: {}",
561            err
562        );
563    }
564}