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}