A file-based task manager
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Atomic-multi-ref recovery: extend gc-refs to repair partial writes

new_task / accept_inbox / pull_from_queue write to several refs in
sequence (task object → property index → namespace binding → queue
index). The order is intentional — an unfinished operation is always
recoverable from the refs that did land — but until now nothing
actively reconciled the drift. tsk-25 picks the "ordered writes +
idempotent reconcile" combo from the original task description.

Write ordering is already correct, so no behavior change there. The
reconcile pass extends `Workspace::gc_refs` (already invoked by
`tsk fix-up`) with two new classes of cleanup:

- ghost namespace bindings: `human → stable` mappings whose stable id
has no task ref. Left behind by a crash between `object::create`
and a later step, or by a force-pushed namespace ref that arrived
ahead of its task refs.
- orphan queue index entries: stables in a queue's index that no
longer resolve to a task ref. Same root cause; previously only the
active queue could be cleaned via `tsk clean`.

Return type widens from `(usize, usize)` → `(usize, usize, usize, usize)`;
the fix-up printer reports each class. Idempotent — second pass is a
no-op (verified by test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+73 -30
+5 -2
src/lib.rs
··· 436 436 println!("backfill-status: set status=open on {n} task(s)"); 437 437 let m = ws.migrate_property_encoding()?; 438 438 println!("migrate-property-encoding: rewrote {m} task(s)"); 439 - let (q, p) = ws.gc_refs()?; 440 - println!("gc-refs: pruned {q} empty queue(s), {p} orphan property entries"); 439 + let (q, p, b, qe) = ws.gc_refs()?; 440 + println!( 441 + "gc-refs: pruned {q} empty queue(s), {p} orphan property entries, \ 442 + {b} ghost namespace binding(s), {qe} orphan queue index entries" 443 + ); 441 444 Ok(()) 442 445 } 443 446 Commands::GitSetup { remote } => {
+68 -28
src/workspace.rs
··· 613 613 Ok(rewritten) 614 614 } 615 615 616 - /// Prune empty / orphan refs under `refs/tsk/*`. Specifically: 616 + /// Prune empty / orphan refs under `refs/tsk/*` and recover from 617 + /// partial multi-ref writes. Returns 618 + /// `(queues_dropped, prop_orphans_dropped, ghost_bindings_dropped, orphan_queue_entries_dropped)`. 619 + /// 620 + /// Recovers: 617 621 /// - empty queues (no index, no inbox) other than the default `tsk` 618 622 /// queue, which always exists by convention; 619 623 /// - property index entries pointing at task refs that no longer 620 624 /// resolve (the index ref itself is auto-deleted by `properties::set` 621 - /// when its last entry goes). 622 - /// Task object refs and namespace refs are left alone — task history is 623 - /// preserved intentionally, and namespace `next` counters are valid even 624 - /// when no live binding uses the latest id. 625 - pub fn gc_refs(&self) -> Result<(usize, usize)> { 625 + /// when its last entry goes); 626 + /// - ghost namespace bindings (`human → stable` where stable's task 627 + /// ref doesn't resolve) — left behind if a crash hit between 628 + /// `object::create` and `namespace::assign_id` and the task object 629 + /// was later GC'd, or if a remote namespace ref was force-pushed 630 + /// ahead of its task refs; 631 + /// - queue index entries pointing at missing task refs (same root 632 + /// cause); also covered by `tsk clean` for the active queue. 633 + /// 634 + /// Task object refs are left alone — task history is preserved 635 + /// intentionally — and namespace `next` counters are valid even when 636 + /// no live binding uses the latest id. 637 + pub fn gc_refs(&self) -> Result<(usize, usize, usize, usize)> { 626 638 let repo = self.repo()?; 627 - let mut queues_pruned = 0usize; 628 - let mut prop_orphans_pruned = 0usize; 639 + let task_exists = |s: &StableId| repo.find_reference(&s.refname()).is_ok(); 640 + let mut queues_pruned = 0; 641 + let mut prop_orphans = 0; 642 + let mut ghost_bindings = 0; 643 + let mut orphan_queue_entries = 0; 644 + 645 + // Empty queues + orphan queue index entries. 629 646 for name in queue::list_names(&repo)? { 630 - if name == queue::DEFAULT_QUEUE { 631 - continue; 647 + let mut q = queue::read(&repo, &name)?; 648 + let before = q.index.len(); 649 + q.index.retain(&task_exists); 650 + if q.index.len() != before { 651 + let removed = before - q.index.len(); 652 + orphan_queue_entries += removed; 653 + queue::write(&repo, &name, &q, "gc-orphan-queue")?; 632 654 } 633 - let q = queue::read(&repo, &name)?; 634 - if q.index.is_empty() && q.inbox.is_empty() { 655 + if name != queue::DEFAULT_QUEUE && q.index.is_empty() && q.inbox.is_empty() { 635 656 if let Ok(mut r) = repo.find_reference(&queue::refname(&name)) { 636 657 r.delete()?; 637 658 queues_pruned += 1; 638 659 } 639 660 } 640 661 } 662 + 663 + // Orphan property index entries. 641 664 for key in properties::list_keys(&repo)? { 642 665 for (stable, _vals) in properties::read(&repo, &key)? { 643 - if repo.find_reference(&stable.refname()).is_err() { 666 + if !task_exists(&stable) { 644 667 properties::set(&repo, &key, &stable, &[], "gc-orphan")?; 645 - prop_orphans_pruned += 1; 668 + prop_orphans += 1; 646 669 } 647 670 } 648 671 } 649 - Ok((queues_pruned, prop_orphans_pruned)) 672 + 673 + // Ghost namespace bindings (human → stable with no task ref). 674 + for ns_name in namespace::list_names(&repo)? { 675 + let mut ns = namespace::read(&repo, &ns_name)?; 676 + let before = ns.mapping.len(); 677 + ns.mapping.retain(|_, s| task_exists(s)); 678 + if ns.mapping.len() != before { 679 + ghost_bindings += before - ns.mapping.len(); 680 + namespace::write(&repo, &ns_name, &ns, "gc-ghost-bindings")?; 681 + } 682 + } 683 + 684 + Ok((queues_pruned, prop_orphans, ghost_bindings, orphan_queue_entries)) 650 685 } 651 686 652 687 /// Drop a task from the active queue and mark it `status=done`. The ··· 1431 1466 } 1432 1467 1433 1468 #[test] 1434 - fn gc_refs_prunes_empty_queues_and_orphan_prop_entries() { 1469 + fn gc_refs_prunes_all_drift_classes() { 1435 1470 let (_d, ws) = fresh_workspace(); 1436 1471 let repo = ws.repo().unwrap(); 1437 1472 1438 - // 1. Empty non-default queue → should be pruned. 1473 + // 1. Empty non-default queue → pruned. 1439 1474 ws.create_queue("empty", None).unwrap(); 1440 - assert!(repo.find_reference(&queue::refname("empty")).is_ok()); 1441 1475 1442 - // 2. Orphan property index entry: write a property into the index 1443 - // pointing at a stable id whose task ref doesn't exist. 1476 + // 2. Orphan property index entry pointing at a missing task ref. 1444 1477 let orphan = StableId("0".repeat(40)); 1445 1478 properties::set(&repo, "ghost", &orphan, &["x".into()], "test").unwrap(); 1446 - assert!(repo.find_reference(&properties::refname("ghost")).is_ok()); 1479 + 1480 + // 3. Ghost namespace binding: assign_id binds before the task ref 1481 + // exists (simulating a partial multi-ref write). 1482 + namespace::assign_id(&repo, "tsk", orphan.clone(), "ghost-bind").unwrap(); 1483 + 1484 + // 4. Orphan queue index entry pointing at the same missing stable. 1485 + queue::push_top(&repo, "tsk", orphan.clone(), "orphan-push").unwrap(); 1447 1486 1448 - let (queues, props) = ws.gc_refs().unwrap(); 1449 - assert_eq!(queues, 1); 1450 - assert_eq!(props, 1); 1487 + let (queues, props, ghosts, qe) = ws.gc_refs().unwrap(); 1488 + assert_eq!(queues, 1, "empty queue pruned"); 1489 + assert_eq!(props, 1, "orphan property entry pruned"); 1490 + assert_eq!(ghosts, 1, "ghost namespace binding dropped"); 1491 + assert_eq!(qe, 1, "orphan queue index entry dropped"); 1451 1492 assert!(repo.find_reference(&queue::refname("empty")).is_err()); 1452 - // The orphan was the index's only entry, so the index ref is dropped too. 1453 1493 assert!(repo.find_reference(&properties::refname("ghost")).is_err()); 1494 + assert!(namespace::human_for(&repo, "tsk", &orphan).unwrap().is_none()); 1454 1495 1455 - // Idempotent: a second pass changes nothing. 1456 - let (q2, p2) = ws.gc_refs().unwrap(); 1457 - assert_eq!((q2, p2), (0, 0)); 1496 + // Idempotent: second pass changes nothing. 1497 + assert_eq!(ws.gc_refs().unwrap(), (0, 0, 0, 0)); 1458 1498 } 1459 1499 1460 1500 #[test]