Skip to main content

agent_doc_element_queue/
lib.rs

1//! Queue element descriptor and local realtime model.
2
3use agent_doc_element::{
4    ElementAuthority, ElementCompositionRole, ElementDescriptor, ElementRealtimeModel,
5    ElementSchedulingRole, ElementShape, ElementSource, ElementWritePolicy,
6};
7use lazily::{ThreadSafeContext, ThreadSafeStateMachine};
8use serde::{Deserialize, Serialize};
9
10pub const DESCRIPTOR: ElementDescriptor = ElementDescriptor {
11    name: "queue",
12    aliases: &[],
13    source: ElementSource::BuiltIn,
14    shape: ElementShape::Component,
15    authority: ElementAuthority::DerivedProjection,
16    write_policy: ElementWritePolicy::ProjectionOnly,
17    scheduling_role: ElementSchedulingRole::ActiveQueue,
18    realtime_model: ElementRealtimeModel::Queue,
19    composition_role: ElementCompositionRole::Consumer,
20    realtime: true,
21};
22
23pub fn descriptor() -> ElementDescriptor {
24    DESCRIPTOR
25}
26
27/// Visible lifecycle state of a single rendered queue item, in ascending lawful
28/// order. This is the coarse lattice the CRDT convergence joins over; richer
29/// orchestration queue state machines refine this state but must map back to it.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum QueueItemLifecycle {
33    /// The item is a live, un-struck queue head and is still actionable.
34    Live,
35    /// The item is struck through (`~~...~~`) and visibly retired. A stale live
36    /// re-emit must never regress past this.
37    Struck,
38}
39
40impl QueueItemLifecycle {
41    /// Position in the total lifecycle order (`Live` = 0, `Struck` = 1).
42    pub fn rank(self) -> u8 {
43        match self {
44            QueueItemLifecycle::Live => 0,
45            QueueItemLifecycle::Struck => 1,
46        }
47    }
48
49    /// The join (`max`) of two lifecycle states. Monotonic: a `Live` re-emit can
50    /// never regress a `Struck` item.
51    pub fn join(self, other: QueueItemLifecycle) -> QueueItemLifecycle {
52        if other.rank() > self.rank() {
53            other
54        } else {
55            self
56        }
57    }
58
59    /// Classify a rendered queue item's first line. A markdown strike (`~~`)
60    /// anywhere on the identity line marks the item answered-and-retired.
61    pub fn classify(item: &str) -> QueueItemLifecycle {
62        let first = item.lines().next().unwrap_or("");
63        if first.contains("~~") {
64            QueueItemLifecycle::Struck
65        } else {
66            QueueItemLifecycle::Live
67        }
68    }
69}
70
71/// Durable identity of a queue head.
72///
73/// Two heads share an identity iff they refer to the same work, regardless of
74/// cosmetic pin / prefix variation (`:pushpin: do [#abcd]`, `do [#abcd]`, and
75/// `[#abcd]` are one identity). Free-text heads key on whitespace-collapsed,
76/// lowercased text so a verbatim re-emit collapses.
77#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case", tag = "kind", content = "key")]
79pub enum QueueItemIdentity {
80    Id(String),
81    FreeText(String),
82}
83
84impl QueueItemIdentity {
85    /// Resolve the durable identity of a queue head prompt. Id-backed heads key
86    /// on the extracted id; everything else keys on normalized free text.
87    pub fn from_prompt(prompt: &str) -> Self {
88        match extract_head_id(prompt) {
89            Some(id) => QueueItemIdentity::Id(id),
90            None => QueueItemIdentity::FreeText(normalize_multiline_dedup_text(prompt)),
91        }
92    }
93
94    /// `true` for an id-backed identity.
95    pub fn is_id_backed(&self) -> bool {
96        matches!(self, QueueItemIdentity::Id(_))
97    }
98}
99
100/// Lifecycle state of a single queue item, in ascending lawful order.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum QueueItemState {
104    Authored,
105    Mirrored,
106    InProgress,
107    Answered,
108    Struck,
109    Reaped,
110}
111
112impl QueueItemState {
113    pub fn rank(self) -> u8 {
114        match self {
115            QueueItemState::Authored => 0,
116            QueueItemState::Mirrored => 1,
117            QueueItemState::InProgress => 2,
118            QueueItemState::Answered => 3,
119            QueueItemState::Struck => 4,
120            QueueItemState::Reaped => 5,
121        }
122    }
123
124    pub fn is_terminal(self) -> bool {
125        matches!(self, QueueItemState::Reaped)
126    }
127
128    /// Project this fine-grained lifecycle state onto the coarse rendered queue
129    /// lattice the CRDT convergence joins over.
130    pub fn lifecycle(self) -> QueueItemLifecycle {
131        match self {
132            QueueItemState::Authored
133            | QueueItemState::Mirrored
134            | QueueItemState::InProgress
135            | QueueItemState::Answered => QueueItemLifecycle::Live,
136            QueueItemState::Struck | QueueItemState::Reaped => QueueItemLifecycle::Struck,
137        }
138    }
139}
140
141/// Events driving a queue item's lifecycle.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum QueueItemEvent {
144    OperatorAuthored,
145    BacklogMirrored,
146    DrainStarted,
147    ExchangeAnswered,
148    StruckThrough,
149    Reaped,
150}
151
152impl QueueItemEvent {
153    /// The lifecycle state this event drives the item toward.
154    pub fn target(self) -> QueueItemState {
155        match self {
156            QueueItemEvent::OperatorAuthored => QueueItemState::Authored,
157            QueueItemEvent::BacklogMirrored => QueueItemState::Mirrored,
158            QueueItemEvent::DrainStarted => QueueItemState::InProgress,
159            QueueItemEvent::ExchangeAnswered => QueueItemState::Answered,
160            QueueItemEvent::StruckThrough => QueueItemState::Struck,
161            QueueItemEvent::Reaped => QueueItemState::Reaped,
162        }
163    }
164}
165
166/// Pure transition: the join (`max`) of the current state and the state the
167/// event drives toward.
168pub fn transition_queue_item(
169    current: &QueueItemState,
170    event: &QueueItemEvent,
171) -> Option<QueueItemState> {
172    let target = event.target();
173    Some(if target.rank() > current.rank() {
174        target
175    } else {
176        *current
177    })
178}
179
180/// Thread-safe lifecycle machine for one queue identity.
181pub struct QueueItemMachine {
182    ctx: ThreadSafeContext,
183    machine: ThreadSafeStateMachine<QueueItemState, QueueItemEvent>,
184}
185
186impl QueueItemMachine {
187    pub fn new(initial: QueueItemState) -> Self {
188        let ctx = ThreadSafeContext::new();
189        let machine = ThreadSafeStateMachine::new(&ctx, initial, transition_queue_item);
190        Self { ctx, machine }
191    }
192
193    pub fn send(&self, event: QueueItemEvent) -> bool {
194        self.machine.send(&self.ctx, event)
195    }
196
197    pub fn state(&self) -> QueueItemState {
198        self.machine.state(&self.ctx)
199    }
200
201    pub fn transition(initial: QueueItemState, event: QueueItemEvent) -> Option<QueueItemState> {
202        let machine = Self::new(initial);
203        if machine.send(event) {
204            Some(machine.state())
205        } else {
206            None
207        }
208    }
209}
210
211pub fn normalize_multiline_dedup_text(text: &str) -> String {
212    text.split_whitespace()
213        .collect::<Vec<_>>()
214        .join(" ")
215        .to_ascii_lowercase()
216}
217
218fn extract_head_id(prompt: &str) -> Option<String> {
219    let stripped = strip_priority_markers(prompt);
220    let trimmed = stripped.trim();
221    let lower = trimmed.to_ascii_lowercase();
222    let topic = lower.strip_prefix("do ").unwrap_or(&lower).trim();
223    let raw = topic
224        .strip_prefix("[#")
225        .and_then(|rest| rest.split_once(']').map(|(id, _)| id))
226        .or_else(|| topic.strip_prefix('#').map(id_prefix))?;
227    if raw.is_empty()
228        || !raw
229            .chars()
230            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
231    {
232        None
233    } else {
234        Some(raw.to_ascii_lowercase())
235    }
236}
237
238fn id_prefix(text: &str) -> &str {
239    text.split(|ch: char| !(ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')))
240        .next()
241        .unwrap_or("")
242}
243
244fn strip_priority_markers(text: &str) -> String {
245    let mut t = text.trim();
246    loop {
247        let trimmed = t.trim_start();
248        let stripped = [
249            "**prioritized**",
250            "__prioritized__",
251            "**pin**",
252            "__pin__",
253            ":pushpin:",
254            ":pin:",
255            "📌",
256            "*prioritized*",
257            "_prioritized_",
258            "*pin*",
259            "_pin_",
260            ":round_pushpin:",
261            "📍",
262            "🚧",
263        ]
264        .iter()
265        .find_map(|marker| trimmed.strip_prefix(marker));
266        match stripped {
267            Some(rest) => t = rest.trim_start(),
268            None => break,
269        }
270    }
271    t.trim().to_string()
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    const ALL_STATES: [QueueItemState; 6] = [
279        QueueItemState::Authored,
280        QueueItemState::Mirrored,
281        QueueItemState::InProgress,
282        QueueItemState::Answered,
283        QueueItemState::Struck,
284        QueueItemState::Reaped,
285    ];
286
287    const ALL_EVENTS: [QueueItemEvent; 6] = [
288        QueueItemEvent::OperatorAuthored,
289        QueueItemEvent::BacklogMirrored,
290        QueueItemEvent::DrainStarted,
291        QueueItemEvent::ExchangeAnswered,
292        QueueItemEvent::StruckThrough,
293        QueueItemEvent::Reaped,
294    ];
295
296    fn t(state: QueueItemState, event: QueueItemEvent) -> QueueItemState {
297        transition_queue_item(&state, &event).expect("transition is total")
298    }
299
300    #[test]
301    fn rank_orders_live_before_struck() {
302        assert!(QueueItemLifecycle::Live.rank() < QueueItemLifecycle::Struck.rank());
303        assert!(QueueItemLifecycle::Live < QueueItemLifecycle::Struck);
304    }
305
306    #[test]
307    fn join_is_monotonic_max() {
308        use QueueItemLifecycle::*;
309        assert_eq!(Live.join(Live), Live);
310        assert_eq!(Live.join(Struck), Struck);
311        assert_eq!(Struck.join(Live), Struck, "struck never regresses to live");
312        assert_eq!(Struck.join(Struck), Struck);
313    }
314
315    #[test]
316    fn join_is_idempotent_and_commutative() {
317        use QueueItemLifecycle::*;
318        for &a in &[Live, Struck] {
319            assert_eq!(a.join(a), a, "idempotent");
320            for &b in &[Live, Struck] {
321                assert_eq!(a.join(b), b.join(a), "commutative");
322            }
323        }
324    }
325
326    #[test]
327    fn classify_detects_strike_on_head_line() {
328        assert_eq!(
329            QueueItemLifecycle::classify("- do [#abcd] fix"),
330            QueueItemLifecycle::Live
331        );
332        assert_eq!(
333            QueueItemLifecycle::classify("- ~~do [#abcd] fix~~"),
334            QueueItemLifecycle::Struck
335        );
336        assert_eq!(
337            QueueItemLifecycle::classify("- ~~do [#abcd] fix~~\n  note line"),
338            QueueItemLifecycle::Struck
339        );
340        assert_eq!(
341            QueueItemLifecycle::classify("- do [#abcd] fix\n  ~~old note~~"),
342            QueueItemLifecycle::Live
343        );
344    }
345
346    #[test]
347    fn queue_item_state_rank_is_strictly_ascending_lifecycle_order() {
348        for pair in ALL_STATES.windows(2) {
349            assert!(pair[0].rank() < pair[1].rank());
350            assert!(pair[0] < pair[1]);
351        }
352    }
353
354    #[test]
355    fn queue_item_transition_is_total_monotonic_and_idempotent() {
356        for &state in &ALL_STATES {
357            for &event in &ALL_EVENTS {
358                let once = t(state, event);
359                let twice = t(once, event);
360                assert!(once.rank() >= state.rank());
361                assert_eq!(once, twice);
362                if event.target().rank() > state.rank() {
363                    assert_eq!(once, event.target());
364                } else {
365                    assert_eq!(once, state);
366                }
367            }
368        }
369    }
370
371    #[test]
372    fn queue_item_reaped_is_absorbing() {
373        for &event in &ALL_EVENTS {
374            assert_eq!(t(QueueItemState::Reaped, event), QueueItemState::Reaped);
375        }
376        assert!(QueueItemState::Reaped.is_terminal());
377    }
378
379    #[test]
380    fn queue_item_machine_drives_canonical_lifecycle() {
381        let machine = QueueItemMachine::new(QueueItemState::Authored);
382        assert_eq!(machine.state(), QueueItemState::Authored);
383
384        assert!(machine.send(QueueItemEvent::BacklogMirrored));
385        assert_eq!(machine.state(), QueueItemState::Mirrored);
386        assert!(machine.send(QueueItemEvent::DrainStarted));
387        assert_eq!(machine.state(), QueueItemState::InProgress);
388        assert!(machine.send(QueueItemEvent::ExchangeAnswered));
389        assert_eq!(machine.state(), QueueItemState::Answered);
390        assert!(machine.send(QueueItemEvent::StruckThrough));
391        assert_eq!(machine.state(), QueueItemState::Struck);
392        assert!(machine.send(QueueItemEvent::Reaped));
393        assert_eq!(machine.state(), QueueItemState::Reaped);
394    }
395
396    #[test]
397    fn queue_item_stale_reemit_storm_never_resurrects_an_advanced_item() {
398        let machine = QueueItemMachine::new(QueueItemState::Authored);
399        machine.send(QueueItemEvent::BacklogMirrored);
400        machine.send(QueueItemEvent::DrainStarted);
401        machine.send(QueueItemEvent::ExchangeAnswered);
402        assert_eq!(machine.state(), QueueItemState::Answered);
403
404        for _ in 0..10 {
405            machine.send(QueueItemEvent::OperatorAuthored);
406            machine.send(QueueItemEvent::BacklogMirrored);
407        }
408        assert_eq!(machine.state(), QueueItemState::Answered);
409    }
410
411    #[test]
412    fn queue_item_lifecycle_projection_commutes_with_join() {
413        for &a in &ALL_STATES {
414            for &b in &ALL_STATES {
415                let joined_state = if a.rank() >= b.rank() { a } else { b };
416                assert_eq!(joined_state.lifecycle(), a.lifecycle().join(b.lifecycle()));
417            }
418        }
419    }
420
421    #[test]
422    fn queue_item_identity_collapses_pin_and_prefix_variants() {
423        let bracketed = QueueItemIdentity::from_prompt(":pushpin: do [#gww8] thing");
424        let bare_prefix = QueueItemIdentity::from_prompt("do [#gww8] thing");
425        let plain = QueueItemIdentity::from_prompt("[#gww8]");
426        assert_eq!(bracketed, QueueItemIdentity::Id("gww8".into()));
427        assert_eq!(bracketed, bare_prefix);
428        assert_eq!(bracketed, plain);
429        assert!(bracketed.is_id_backed());
430    }
431
432    #[test]
433    fn queue_item_free_text_identity_keys_on_normalized_text() {
434        let a = QueueItemIdentity::from_prompt("Still getting JB File Cache Conflict dialogs.");
435        let b = QueueItemIdentity::from_prompt("still getting   jb file cache conflict dialogs.");
436        let other = QueueItemIdentity::from_prompt("Opencode reverse-orders numeric lists.");
437        assert_eq!(a, b);
438        assert_ne!(a, other);
439        assert!(!a.is_id_backed());
440    }
441}