Skip to main content

bn/commands/
claim.rs

1use std::path::Path;
2use std::process::Command as ShellCommand;
3
4use anyhow::{anyhow, Context, Result};
5use chrono::Utc;
6
7use crate::bean::{AttemptOutcome, AttemptRecord, Bean, Status};
8use crate::config::Config;
9use crate::discovery::find_bean_file;
10use crate::index::Index;
11
12/// Try to get the current git HEAD SHA. Returns None if not in a git repo.
13fn git_head_sha(working_dir: &Path) -> Option<String> {
14    ShellCommand::new("git")
15        .args(["rev-parse", "HEAD"])
16        .current_dir(working_dir)
17        .output()
18        .ok()
19        .filter(|o| o.status.success())
20        .and_then(|o| String::from_utf8(o.stdout).ok())
21        .map(|s| s.trim().to_string())
22        .filter(|s| !s.is_empty())
23}
24
25/// Run the verify command and return whether it passed (exit 0).
26fn run_verify_check(verify_cmd: &str, project_root: &Path) -> Result<bool> {
27    let output = ShellCommand::new("sh")
28        .args(["-c", verify_cmd])
29        .current_dir(project_root)
30        .stdout(std::process::Stdio::null())
31        .stderr(std::process::Stdio::null())
32        .status()
33        .with_context(|| format!("Failed to execute verify command: {}", verify_cmd))?;
34
35    Ok(output.success())
36}
37
38/// Claim a bean for work.
39///
40/// Sets status to InProgress, records who claimed it and when.
41/// The bean must be in Open status to be claimed.
42///
43/// If the bean has a verify command and `force` is false, the verify command
44/// is run first. If it already passes, the claim is rejected (nothing to do).
45/// If it fails, the claim is granted with `fail_first: true` and the current
46/// git HEAD SHA is stored as `checkpoint`.
47pub fn cmd_claim(beans_dir: &Path, id: &str, by: Option<String>, force: bool) -> Result<()> {
48    let bean_path = find_bean_file(beans_dir, id).map_err(|_| anyhow!("Bean not found: {}", id))?;
49
50    let mut bean =
51        Bean::from_file(&bean_path).with_context(|| format!("Failed to load bean: {}", id))?;
52
53    if bean.status != Status::Open {
54        return Err(anyhow!(
55            "Bean {} is {} -- only open beans can be claimed",
56            id,
57            bean.status
58        ));
59    }
60
61    // Warn if bean has no verify command (GOAL vs SPEC)
62    let has_verify = bean.verify.as_ref().is_some_and(|v| !v.trim().is_empty());
63    if !has_verify {
64        eprintln!(
65            "Warning: Claiming GOAL (no verify). Consider decomposing with: bn create \"spec\" --parent {} --verify \"test\"",
66            id
67        );
68    }
69
70    // Verify-on-claim: run verify before granting claim (TDD enforcement)
71    if has_verify && !force {
72        let project_root = beans_dir
73            .parent()
74            .ok_or_else(|| anyhow!("Cannot determine project root from beans dir"))?;
75        let verify_cmd = bean.verify.as_ref().unwrap();
76
77        eprintln!("Running verify before claim: {}", verify_cmd);
78        let passed = run_verify_check(verify_cmd, project_root)?;
79
80        if passed {
81            return Err(anyhow!(
82                "Cannot claim bean {}: verify already passes\n\n\
83                 The verify command succeeded before any work was done.\n\
84                 This means either the test is bogus or the work is already complete.\n\n\
85                 Use --force to override.",
86                id
87            ));
88        }
89
90        // Verify failed — good, this proves the test is meaningful
91        bean.fail_first = true;
92        bean.checkpoint = git_head_sha(project_root);
93    }
94
95    // Check token count against max_tokens config
96    if let Some(tokens) = bean.tokens {
97        let config = Config::load(beans_dir).unwrap_or_else(|_| Config {
98            project: String::new(),
99            next_id: 1,
100            auto_close_parent: true,
101            max_tokens: 30000,
102            run: None,
103            plan: None,
104            max_loops: 10,
105            max_concurrent: 4,
106            poll_interval: 30,
107            extends: vec![],
108            rules_file: None,
109            file_locking: false,
110            on_close: None,
111            on_fail: None,
112            post_plan: None,
113            verify_timeout: None,
114            review: None,
115        });
116        if tokens > config.max_tokens as u64 {
117            return Err(anyhow!(
118                "Cannot claim bean {}: too large for implementation
119
120  {} tokens > {} limit
121
122This bean appears to be a GOAL (high-level outcome) rather than a SPEC 
123(concrete, implementable contract).
124
125To make it implementable:
1261. Break it into smaller specs using: bn create \"spec title\" --parent {}
1272. Each spec should have clear inputs/outputs and a verify command
1283. Or use the decompose skill to interactively clarify requirements
129
130See: Goal → Spec → Test framework
131  - GOAL = WHY (this bean)
132  - SPEC = WHAT (child beans with contracts)
133  - TEST = verify command (proves spec is met)",
134                id,
135                tokens,
136                config.max_tokens,
137                id
138            ));
139        }
140    }
141
142    let now = Utc::now();
143    bean.status = Status::InProgress;
144    bean.claimed_by = by.clone();
145    bean.claimed_at = Some(now);
146    bean.updated_at = now;
147
148    // Start a new attempt in the attempt log (for memory system tracking)
149    let attempt_num = bean.attempt_log.len() as u32 + 1;
150    bean.attempt_log.push(AttemptRecord {
151        num: attempt_num,
152        outcome: AttemptOutcome::Abandoned, // default until close/release updates it
153        notes: None,
154        agent: by.clone(),
155        started_at: Some(now),
156        finished_at: None,
157    });
158
159    bean.to_file(&bean_path)
160        .with_context(|| format!("Failed to save bean: {}", id))?;
161
162    let claimer = by.as_deref().unwrap_or("anonymous");
163    println!("Claimed bean {}: {} (by {})", id, bean.title, claimer);
164
165    // Rebuild index
166    let index = Index::build(beans_dir).with_context(|| "Failed to rebuild index")?;
167    index
168        .save(beans_dir)
169        .with_context(|| "Failed to save index")?;
170
171    Ok(())
172}
173
174/// Release a claim on a bean.
175///
176/// Clears claimed_by/claimed_at and sets status back to Open.
177pub fn cmd_release(beans_dir: &Path, id: &str) -> Result<()> {
178    let bean_path = find_bean_file(beans_dir, id).map_err(|_| anyhow!("Bean not found: {}", id))?;
179
180    let mut bean =
181        Bean::from_file(&bean_path).with_context(|| format!("Failed to load bean: {}", id))?;
182
183    let now = Utc::now();
184
185    // Finalize the current attempt as abandoned (if one is in progress)
186    if let Some(attempt) = bean.attempt_log.last_mut() {
187        if attempt.finished_at.is_none() {
188            attempt.outcome = AttemptOutcome::Abandoned;
189            attempt.finished_at = Some(now);
190        }
191    }
192
193    bean.claimed_by = None;
194    bean.claimed_at = None;
195    bean.status = Status::Open;
196    bean.updated_at = now;
197
198    bean.to_file(&bean_path)
199        .with_context(|| format!("Failed to save bean: {}", id))?;
200
201    println!("Released claim on bean {}: {}", id, bean.title);
202
203    // Rebuild index
204    let index = Index::build(beans_dir).with_context(|| "Failed to rebuild index")?;
205    index
206        .save(beans_dir)
207        .with_context(|| "Failed to save index")?;
208
209    Ok(())
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use std::fs;
216    use tempfile::TempDir;
217
218    fn setup_test_beans_dir() -> (TempDir, std::path::PathBuf) {
219        let dir = TempDir::new().unwrap();
220        let beans_dir = dir.path().join(".beans");
221        fs::create_dir(&beans_dir).unwrap();
222        (dir, beans_dir)
223    }
224
225    #[test]
226    fn test_claim_open_bean() {
227        let (_dir, beans_dir) = setup_test_beans_dir();
228        let bean = Bean::new("1", "Task");
229        bean.to_file(beans_dir.join("1.yaml")).unwrap();
230
231        cmd_claim(&beans_dir, "1", Some("alice".to_string()), true).unwrap();
232
233        let updated = Bean::from_file(beans_dir.join("1.yaml")).unwrap();
234        assert_eq!(updated.status, Status::InProgress);
235        assert_eq!(updated.claimed_by, Some("alice".to_string()));
236        assert!(updated.claimed_at.is_some());
237    }
238
239    #[test]
240    fn test_claim_without_by() {
241        let (_dir, beans_dir) = setup_test_beans_dir();
242        let bean = Bean::new("1", "Task");
243        bean.to_file(beans_dir.join("1.yaml")).unwrap();
244
245        cmd_claim(&beans_dir, "1", None, true).unwrap();
246
247        let updated = Bean::from_file(beans_dir.join("1.yaml")).unwrap();
248        assert_eq!(updated.status, Status::InProgress);
249        assert_eq!(updated.claimed_by, None);
250        assert!(updated.claimed_at.is_some());
251    }
252
253    #[test]
254    fn test_claim_non_open_bean_fails() {
255        let (_dir, beans_dir) = setup_test_beans_dir();
256        let mut bean = Bean::new("1", "Task");
257        bean.status = Status::InProgress;
258        bean.to_file(beans_dir.join("1.yaml")).unwrap();
259
260        let result = cmd_claim(&beans_dir, "1", Some("bob".to_string()), true);
261        assert!(result.is_err());
262    }
263
264    #[test]
265    fn test_claim_closed_bean_fails() {
266        let (_dir, beans_dir) = setup_test_beans_dir();
267        let mut bean = Bean::new("1", "Task");
268        bean.status = Status::Closed;
269        bean.to_file(beans_dir.join("1.yaml")).unwrap();
270
271        let result = cmd_claim(&beans_dir, "1", Some("bob".to_string()), true);
272        assert!(result.is_err());
273    }
274
275    #[test]
276    fn test_claim_nonexistent_bean_fails() {
277        let (_dir, beans_dir) = setup_test_beans_dir();
278        let result = cmd_claim(&beans_dir, "99", Some("alice".to_string()), true);
279        assert!(result.is_err());
280    }
281
282    #[test]
283    fn test_release_claimed_bean() {
284        let (_dir, beans_dir) = setup_test_beans_dir();
285        let mut bean = Bean::new("1", "Task");
286        bean.status = Status::InProgress;
287        bean.claimed_by = Some("alice".to_string());
288        bean.claimed_at = Some(Utc::now());
289        bean.to_file(beans_dir.join("1.yaml")).unwrap();
290
291        cmd_release(&beans_dir, "1").unwrap();
292
293        let updated = Bean::from_file(beans_dir.join("1.yaml")).unwrap();
294        assert_eq!(updated.status, Status::Open);
295        assert_eq!(updated.claimed_by, None);
296        assert_eq!(updated.claimed_at, None);
297    }
298
299    #[test]
300    fn test_release_nonexistent_bean_fails() {
301        let (_dir, beans_dir) = setup_test_beans_dir();
302        let result = cmd_release(&beans_dir, "99");
303        assert!(result.is_err());
304    }
305
306    #[test]
307    fn test_claim_rebuilds_index() {
308        let (_dir, beans_dir) = setup_test_beans_dir();
309        let bean = Bean::new("1", "Task");
310        bean.to_file(beans_dir.join("1.yaml")).unwrap();
311
312        cmd_claim(&beans_dir, "1", Some("alice".to_string()), true).unwrap();
313
314        let index = Index::load(&beans_dir).unwrap();
315        assert_eq!(index.beans.len(), 1);
316        let entry = &index.beans[0];
317        assert_eq!(entry.status, Status::InProgress);
318    }
319
320    #[test]
321    fn test_release_rebuilds_index() {
322        let (_dir, beans_dir) = setup_test_beans_dir();
323        let mut bean = Bean::new("1", "Task");
324        bean.status = Status::InProgress;
325        bean.to_file(beans_dir.join("1.yaml")).unwrap();
326
327        cmd_release(&beans_dir, "1").unwrap();
328
329        let index = Index::load(&beans_dir).unwrap();
330        assert_eq!(index.beans.len(), 1);
331        let entry = &index.beans[0];
332        assert_eq!(entry.status, Status::Open);
333    }
334
335    #[test]
336    fn test_claim_bean_exceeding_max_tokens_fails() {
337        let (_dir, beans_dir) = setup_test_beans_dir();
338
339        // Create config with max_tokens = 30000
340        let config = crate::config::Config {
341            project: "test".to_string(),
342            next_id: 2,
343            auto_close_parent: true,
344            max_tokens: 30000,
345            run: None,
346            plan: None,
347            max_loops: 10,
348            max_concurrent: 4,
349            poll_interval: 30,
350            extends: vec![],
351            rules_file: None,
352            file_locking: false,
353            on_close: None,
354            on_fail: None,
355            post_plan: None,
356            verify_timeout: None,
357            review: None,
358        };
359        config.save(&beans_dir).unwrap();
360
361        // Create bean with tokens > max_tokens
362        let mut bean = Bean::new("1", "Large Bean");
363        bean.tokens = Some(45000);
364        bean.to_file(beans_dir.join("1.yaml")).unwrap();
365
366        let result = cmd_claim(&beans_dir, "1", Some("alice".to_string()), true);
367        assert!(result.is_err());
368        let err_msg = result.unwrap_err().to_string();
369        assert!(err_msg.contains("too large"));
370        assert!(err_msg.contains("45000"));
371        assert!(err_msg.contains("30000"));
372        assert!(err_msg.contains("GOAL"));
373        assert!(err_msg.contains("SPEC"));
374        assert!(err_msg.contains("--parent"));
375    }
376
377    #[test]
378    fn test_claim_bean_under_max_tokens_succeeds() {
379        let (_dir, beans_dir) = setup_test_beans_dir();
380
381        // Create config with max_tokens = 30000
382        let config = crate::config::Config {
383            project: "test".to_string(),
384            next_id: 2,
385            auto_close_parent: true,
386            max_tokens: 30000,
387            run: None,
388            plan: None,
389            max_loops: 10,
390            max_concurrent: 4,
391            poll_interval: 30,
392            extends: vec![],
393            rules_file: None,
394            file_locking: false,
395            on_close: None,
396            on_fail: None,
397            post_plan: None,
398            verify_timeout: None,
399            review: None,
400        };
401        config.save(&beans_dir).unwrap();
402
403        // Create bean with tokens < max_tokens
404        let mut bean = Bean::new("1", "Small Bean");
405        bean.tokens = Some(15000);
406        bean.to_file(beans_dir.join("1.yaml")).unwrap();
407
408        let result = cmd_claim(&beans_dir, "1", Some("alice".to_string()), true);
409        assert!(result.is_ok());
410
411        let updated = Bean::from_file(beans_dir.join("1.yaml")).unwrap();
412        assert_eq!(updated.status, Status::InProgress);
413    }
414
415    #[test]
416    fn test_claim_bean_without_tokens_succeeds() {
417        let (_dir, beans_dir) = setup_test_beans_dir();
418
419        // Create config
420        let config = crate::config::Config {
421            project: "test".to_string(),
422            next_id: 2,
423            auto_close_parent: true,
424            max_tokens: 30000,
425            run: None,
426            plan: None,
427            max_loops: 10,
428            max_concurrent: 4,
429            poll_interval: 30,
430            extends: vec![],
431            rules_file: None,
432            file_locking: false,
433            on_close: None,
434            on_fail: None,
435            post_plan: None,
436            verify_timeout: None,
437            review: None,
438        };
439        config.save(&beans_dir).unwrap();
440
441        // Create bean without tokens field (None)
442        let bean = Bean::new("1", "No Token Count");
443        bean.to_file(beans_dir.join("1.yaml")).unwrap();
444
445        let result = cmd_claim(&beans_dir, "1", Some("alice".to_string()), true);
446        assert!(result.is_ok());
447
448        let updated = Bean::from_file(beans_dir.join("1.yaml")).unwrap();
449        assert_eq!(updated.status, Status::InProgress);
450    }
451
452    #[test]
453    fn test_claim_bean_at_exact_limit_succeeds() {
454        let (_dir, beans_dir) = setup_test_beans_dir();
455
456        // Create config with max_tokens = 30000
457        let config = crate::config::Config {
458            project: "test".to_string(),
459            next_id: 2,
460            auto_close_parent: true,
461            max_tokens: 30000,
462            run: None,
463            plan: None,
464            max_loops: 10,
465            max_concurrent: 4,
466            poll_interval: 30,
467            extends: vec![],
468            rules_file: None,
469            file_locking: false,
470            on_close: None,
471            on_fail: None,
472            post_plan: None,
473            verify_timeout: None,
474            review: None,
475        };
476        config.save(&beans_dir).unwrap();
477
478        // Create bean with tokens == max_tokens (exactly at limit)
479        let mut bean = Bean::new("1", "Exact Limit Bean");
480        bean.tokens = Some(30000);
481        bean.to_file(beans_dir.join("1.yaml")).unwrap();
482
483        let result = cmd_claim(&beans_dir, "1", Some("alice".to_string()), true);
484        assert!(result.is_ok());
485    }
486
487    #[test]
488    fn test_claim_bean_without_verify_succeeds_with_warning() {
489        let (_dir, beans_dir) = setup_test_beans_dir();
490
491        // Create bean without verify (this is a GOAL, not a SPEC)
492        let bean = Bean::new("1", "Add authentication");
493        // bean.verify is None by default
494        bean.to_file(beans_dir.join("1.yaml")).unwrap();
495
496        // Claim should succeed (warning is printed but doesn't block)
497        let result = cmd_claim(&beans_dir, "1", Some("alice".to_string()), true);
498        assert!(result.is_ok());
499
500        let updated = Bean::from_file(beans_dir.join("1.yaml")).unwrap();
501        assert_eq!(updated.status, Status::InProgress);
502        assert_eq!(updated.claimed_by, Some("alice".to_string()));
503    }
504
505    #[test]
506    fn test_claim_bean_with_verify_succeeds() {
507        let (_dir, beans_dir) = setup_test_beans_dir();
508
509        // Create bean with verify (this is a SPEC)
510        let mut bean = Bean::new("1", "Add login endpoint");
511        bean.verify = Some("cargo test login".to_string());
512        bean.to_file(beans_dir.join("1.yaml")).unwrap();
513
514        // Claim should succeed without warning
515        let result = cmd_claim(&beans_dir, "1", Some("alice".to_string()), true);
516        assert!(result.is_ok());
517
518        let updated = Bean::from_file(beans_dir.join("1.yaml")).unwrap();
519        assert_eq!(updated.status, Status::InProgress);
520    }
521
522    #[test]
523    fn test_claim_bean_with_empty_verify_warns() {
524        let (_dir, beans_dir) = setup_test_beans_dir();
525
526        // Create bean with empty verify string (should be treated as no verify)
527        let mut bean = Bean::new("1", "Vague task");
528        bean.verify = Some("   ".to_string()); // whitespace only
529        bean.to_file(beans_dir.join("1.yaml")).unwrap();
530
531        // Claim should succeed (warning is printed but doesn't block)
532        let result = cmd_claim(&beans_dir, "1", Some("alice".to_string()), true);
533        assert!(result.is_ok());
534
535        let updated = Bean::from_file(beans_dir.join("1.yaml")).unwrap();
536        assert_eq!(updated.status, Status::InProgress);
537    }
538
539    // =================================================================
540    // verify_on_claim tests
541    // =================================================================
542
543    #[test]
544    fn verify_on_claim_passing_verify_rejected() {
545        let (_dir, beans_dir) = setup_test_beans_dir();
546
547        // Bean with verify that passes immediately ("true" exits 0)
548        let mut bean = Bean::new("1", "Already done");
549        bean.verify = Some("true".to_string());
550        bean.to_file(beans_dir.join("1.yaml")).unwrap();
551
552        // Claim without force — should be rejected because verify passes
553        let result = cmd_claim(&beans_dir, "1", Some("alice".to_string()), false);
554        assert!(result.is_err());
555        let err_msg = result.unwrap_err().to_string();
556        assert!(err_msg.contains("verify already passes"));
557        assert!(err_msg.contains("--force"));
558
559        // Bean should still be open (claim was rejected)
560        let unchanged = Bean::from_file(beans_dir.join("1.yaml")).unwrap();
561        assert_eq!(unchanged.status, Status::Open);
562    }
563
564    #[test]
565    fn verify_on_claim_failing_verify_succeeds() {
566        let (_dir, beans_dir) = setup_test_beans_dir();
567
568        // Bean with verify that fails ("false" exits 1)
569        let mut bean = Bean::new("1", "Real work needed");
570        bean.verify = Some("false".to_string());
571        bean.to_file(beans_dir.join("1.yaml")).unwrap();
572
573        // Claim without force — should succeed because verify fails
574        let result = cmd_claim(&beans_dir, "1", Some("alice".to_string()), false);
575        assert!(result.is_ok());
576
577        let updated = Bean::from_file(beans_dir.join("1.yaml")).unwrap();
578        assert_eq!(updated.status, Status::InProgress);
579        assert_eq!(updated.claimed_by, Some("alice".to_string()));
580        assert!(
581            updated.fail_first,
582            "fail_first should be set when verify fails at claim time"
583        );
584    }
585
586    #[test]
587    fn verify_on_claim_force_overrides() {
588        let (_dir, beans_dir) = setup_test_beans_dir();
589
590        // Bean with verify that passes immediately
591        let mut bean = Bean::new("1", "Force claim");
592        bean.verify = Some("true".to_string());
593        bean.to_file(beans_dir.join("1.yaml")).unwrap();
594
595        // Claim with force — should succeed even though verify passes
596        let result = cmd_claim(&beans_dir, "1", Some("alice".to_string()), true);
597        assert!(result.is_ok());
598
599        let updated = Bean::from_file(beans_dir.join("1.yaml")).unwrap();
600        assert_eq!(updated.status, Status::InProgress);
601        assert_eq!(updated.claimed_by, Some("alice".to_string()));
602    }
603
604    #[test]
605    fn verify_on_claim_checkpoint_sha_stored() {
606        let (_dir, beans_dir) = setup_test_beans_dir();
607
608        // Bean with verify that fails
609        let mut bean = Bean::new("1", "Checkpoint test");
610        bean.verify = Some("false".to_string());
611        bean.to_file(beans_dir.join("1.yaml")).unwrap();
612
613        // Initialize a git repo in the temp dir so we get a real SHA
614        let project_root = beans_dir.parent().unwrap();
615        std::process::Command::new("git")
616            .args(["init"])
617            .current_dir(project_root)
618            .output()
619            .unwrap();
620        std::process::Command::new("git")
621            .args(["add", "."])
622            .current_dir(project_root)
623            .output()
624            .unwrap();
625        std::process::Command::new("git")
626            .args(["commit", "-m", "init", "--allow-empty"])
627            .current_dir(project_root)
628            .env("GIT_AUTHOR_NAME", "test")
629            .env("GIT_AUTHOR_EMAIL", "test@test.com")
630            .env("GIT_COMMITTER_NAME", "test")
631            .env("GIT_COMMITTER_EMAIL", "test@test.com")
632            .output()
633            .unwrap();
634
635        let result = cmd_claim(&beans_dir, "1", Some("alice".to_string()), false);
636        assert!(result.is_ok());
637
638        let updated = Bean::from_file(beans_dir.join("1.yaml")).unwrap();
639        assert!(
640            updated.checkpoint.is_some(),
641            "checkpoint SHA should be stored"
642        );
643        let sha = updated.checkpoint.unwrap();
644        assert_eq!(sha.len(), 40, "SHA should be 40 hex chars, got: {}", sha);
645        assert!(
646            sha.chars().all(|c| c.is_ascii_hexdigit()),
647            "SHA should be hex"
648        );
649    }
650
651    #[test]
652    fn verify_on_claim_no_verify_skips_check() {
653        let (_dir, beans_dir) = setup_test_beans_dir();
654
655        // Bean without verify — should not run verify check
656        let bean = Bean::new("1", "No verify");
657        bean.to_file(beans_dir.join("1.yaml")).unwrap();
658
659        let result = cmd_claim(&beans_dir, "1", Some("alice".to_string()), false);
660        assert!(result.is_ok());
661
662        let updated = Bean::from_file(beans_dir.join("1.yaml")).unwrap();
663        assert_eq!(updated.status, Status::InProgress);
664        assert!(
665            !updated.fail_first,
666            "fail_first should not be set without verify"
667        );
668        assert!(updated.checkpoint.is_none(), "no checkpoint without verify");
669    }
670
671    // =====================================================================
672    // Attempt Tracking Tests
673    // =====================================================================
674
675    #[test]
676    fn claim_starts_attempt() {
677        let (_dir, beans_dir) = setup_test_beans_dir();
678        let bean = Bean::new("1", "Task");
679        bean.to_file(beans_dir.join("1.yaml")).unwrap();
680
681        cmd_claim(&beans_dir, "1", Some("agent-1".to_string()), true).unwrap();
682
683        let updated = Bean::from_file(beans_dir.join("1.yaml")).unwrap();
684        assert_eq!(updated.attempt_log.len(), 1);
685        assert_eq!(updated.attempt_log[0].num, 1);
686        assert_eq!(updated.attempt_log[0].agent, Some("agent-1".to_string()));
687        assert!(updated.attempt_log[0].started_at.is_some());
688        assert!(updated.attempt_log[0].finished_at.is_none());
689    }
690
691    #[test]
692    fn release_marks_attempt_abandoned() {
693        let (_dir, beans_dir) = setup_test_beans_dir();
694        let mut bean = Bean::new("1", "Task");
695        bean.status = Status::InProgress;
696        bean.claimed_by = Some("agent-1".to_string());
697        bean.attempt_log.push(AttemptRecord {
698            num: 1,
699            outcome: AttemptOutcome::Abandoned,
700            notes: None,
701            agent: Some("agent-1".to_string()),
702            started_at: Some(Utc::now()),
703            finished_at: None,
704        });
705        bean.to_file(beans_dir.join("1.yaml")).unwrap();
706
707        cmd_release(&beans_dir, "1").unwrap();
708
709        let updated = Bean::from_file(beans_dir.join("1.yaml")).unwrap();
710        assert_eq!(updated.attempt_log.len(), 1);
711        assert_eq!(updated.attempt_log[0].outcome, AttemptOutcome::Abandoned);
712        assert!(updated.attempt_log[0].finished_at.is_some());
713    }
714
715    #[test]
716    fn multiple_claims_accumulate_attempts() {
717        let (_dir, beans_dir) = setup_test_beans_dir();
718        let bean = Bean::new("1", "Task");
719        bean.to_file(beans_dir.join("1.yaml")).unwrap();
720
721        // First claim
722        cmd_claim(&beans_dir, "1", Some("agent-1".to_string()), true).unwrap();
723        // Release
724        cmd_release(&beans_dir, "1").unwrap();
725        // Second claim
726        cmd_claim(&beans_dir, "1", Some("agent-2".to_string()), true).unwrap();
727
728        let updated = Bean::from_file(beans_dir.join("1.yaml")).unwrap();
729        assert_eq!(updated.attempt_log.len(), 2);
730        assert_eq!(updated.attempt_log[0].num, 1);
731        assert_eq!(updated.attempt_log[0].outcome, AttemptOutcome::Abandoned);
732        assert!(updated.attempt_log[0].finished_at.is_some());
733        assert_eq!(updated.attempt_log[1].num, 2);
734        assert_eq!(updated.attempt_log[1].agent, Some("agent-2".to_string()));
735        assert!(updated.attempt_log[1].finished_at.is_none());
736    }
737}