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
12pub struct ClaimParams {
14 pub by: Option<String>,
16 pub force: bool,
18}
19
20#[derive(Debug)]
22pub struct ClaimResult {
23 pub unit: Unit,
24 pub path: PathBuf,
25 pub claimer: String,
27 pub is_goal: bool,
29}
30
31pub struct ReleaseResult {
33 pub unit: Unit,
34 pub path: PathBuf,
35}
36
37fn 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
50fn 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
63pub 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 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 unit.fail_first = true;
110 unit.checkpoint = git_head_sha(project_root);
111 }
112
113 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 let attempt_num = unit.attempt_log.len() as u32 + 1;
127 unit.attempt_log.push(AttemptRecord {
128 num: attempt_num,
129 outcome: AttemptOutcome::Abandoned, 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 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
153pub 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 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 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 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 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 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 #[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 claim(&bd, "1", force_params(Some("agent-1"))).unwrap();
525 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 let result = release(&bd, "1").unwrap();
532
533 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 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 #[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 claim(&bd, "1", force_params(Some("agent-1"))).unwrap();
553
554 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}