Skip to main content

agent_doc_core/
queue_item_lifecycle.rs

1//! Pure queue-item lifecycle lattice shared by CRDT convergence (`agent-doc-core`)
2//! and the orchestration-layer queue state machine
3//! (`agent_doc_orchestration::queue_item_state_machine`, `#queuestatemachine1`).
4//!
5//! ## Why this lives in core (`#queuestatemachine2` / `#qheadresidue`)
6//!
7//! The orchestration `queue_item_state_machine` models a queue head's lifecycle as
8//! a total order whose transition is the **join** (`max`) over lifecycle rank, so a
9//! stale re-emit "cannot resurrect the item as a fresh queue head." That guarantee
10//! only held in isolation: the actual CRDT queue convergence
11//! ([`crate::crdt::merge_by_component`]) bypassed it and ran a blind Yrs leaf merge
12//! per item, which let a stale un-strike from the persisted `.yrs` base win and
13//! resurrect a struck head every cycle (the live churn).
14//!
15//! `agent-doc-core` is the lower crate (orchestration depends on it, not the
16//! reverse), so the pure lattice the convergence needs to consult lives here. The
17//! orchestration `queue_item_state_machine` re-exports [`QueueItemLifecycle`] and
18//! keeps its richer event-driven `QueueItemState` machine on top of it, so there is
19//! a single authoritative lifecycle model rather than two diverging copies.
20//!
21//! ## The structural guarantee, applied to convergence
22//!
23//! A queue item's *visible* lifecycle state is recoverable from its rendered text:
24//! a struck (`~~…~~`) item is answered-and-retired; an un-struck item is live. The
25//! lattice orders `Live < Struck`. Reconciling a matched item across the three
26//! merge sides is the **join** of their visible states: if any authoritative side
27//! says `Struck`, a stale `Live` contribution is a no-op and the item stays struck.
28//! Resurrection becomes structurally impossible instead of patched after the fact.
29
30use serde::{Deserialize, Serialize};
31
32/// Visible lifecycle state of a single rendered queue item, in ascending lawful
33/// order. This is the coarse lattice the CRDT convergence joins over; the
34/// orchestration `QueueItemState` is a finer-grained refinement that maps onto it.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum QueueItemLifecycle {
38    /// The item is a live (un-struck) queue head — still actionable.
39    Live,
40    /// The item is struck through (`~~…~~`) — answered and visibly retired. A
41    /// stale live re-emit must never regress past this.
42    Struck,
43}
44
45impl QueueItemLifecycle {
46    /// Position in the total lifecycle order (`Live` = 0, `Struck` = 1). The join
47    /// compares ranks.
48    pub fn rank(self) -> u8 {
49        match self {
50            QueueItemLifecycle::Live => 0,
51            QueueItemLifecycle::Struck => 1,
52        }
53    }
54
55    /// The join (`max`) of two lifecycle states — the lawful reconciliation of two
56    /// sides' visible states. Monotonic: a `Live` re-emit can never regress a
57    /// `Struck` item. Mirrors `transition_queue_item`'s rank join in the
58    /// orchestration state machine.
59    pub fn join(self, other: QueueItemLifecycle) -> QueueItemLifecycle {
60        if other.rank() > self.rank() {
61            other
62        } else {
63            self
64        }
65    }
66
67    /// Classify a rendered queue item's first line. A markdown strike (`~~`)
68    /// anywhere on the identity line marks the item answered-and-retired.
69    ///
70    /// Keyed off the first line only (continuation lines are body, not the head
71    /// identity) so a struck head with an unstruck continuation still classifies
72    /// as `Struck`.
73    pub fn classify(item: &str) -> QueueItemLifecycle {
74        let first = item.lines().next().unwrap_or("");
75        if first.contains("~~") {
76            QueueItemLifecycle::Struck
77        } else {
78            QueueItemLifecycle::Live
79        }
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn rank_orders_live_before_struck() {
89        assert!(QueueItemLifecycle::Live.rank() < QueueItemLifecycle::Struck.rank());
90        assert!(QueueItemLifecycle::Live < QueueItemLifecycle::Struck);
91    }
92
93    #[test]
94    fn join_is_monotonic_max() {
95        use QueueItemLifecycle::*;
96        assert_eq!(Live.join(Live), Live);
97        assert_eq!(Live.join(Struck), Struck);
98        assert_eq!(Struck.join(Live), Struck, "struck never regresses to live");
99        assert_eq!(Struck.join(Struck), Struck);
100    }
101
102    #[test]
103    fn join_is_idempotent_and_commutative() {
104        use QueueItemLifecycle::*;
105        for &a in &[Live, Struck] {
106            assert_eq!(a.join(a), a, "idempotent");
107            for &b in &[Live, Struck] {
108                assert_eq!(a.join(b), b.join(a), "commutative");
109            }
110        }
111    }
112
113    #[test]
114    fn classify_detects_strike_on_head_line() {
115        assert_eq!(
116            QueueItemLifecycle::classify("- do [#abcd] fix"),
117            QueueItemLifecycle::Live
118        );
119        assert_eq!(
120            QueueItemLifecycle::classify("- ~~do [#abcd] fix~~"),
121            QueueItemLifecycle::Struck
122        );
123        // Strike on the head, plain continuation → still struck.
124        assert_eq!(
125            QueueItemLifecycle::classify("- ~~do [#abcd] fix~~\n  note line"),
126            QueueItemLifecycle::Struck
127        );
128        // Continuation-only strike does not retire the head.
129        assert_eq!(
130            QueueItemLifecycle::classify("- do [#abcd] fix\n  ~~old note~~"),
131            QueueItemLifecycle::Live
132        );
133    }
134}