Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:57:43.204Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

omega_send_queue.rs

749 lines · 26.7 KB · rust
1//! Durable admission for messages sent during a running turn (omega#79).
2//!
3//! `OMEGA-DELTA-0032`.
4//!
5//! ## What was wrong
6//!
7//! Omega inherited a real queue. `MessageQueue` distinguishes a steer from an
8//! enqueue, and its state machine is careful about the cancel it has to absorb.
9//! Two things were missing, and the issue's own falsifier names both.
10//!
11//! **"Queue state lives only in renderer memory."** It did. `MessageQueue` is a
12//! field on `ThreadView`, holds live GPUI editor handles, and dies with
13//! the view. Close the panel, reconnect, or restart, and a message the composer
14//! had already acknowledged as queued was gone with no trace that it ever
15//! existed. This module is the durable half: an item is written down *before*
16//! the UI says it is queued, so what the user was told and what survives a
17//! restart are the same fact.
18//!
19//! **"A second send reaches a running provider turn without a guard."**
20//! `sync_queue_flag_to_native_thread` set the boundary flag on
21//! `agent::Thread` and did nothing for anything else, so an external ACP thread
22//! and an engine lane both fell through to `dispatch_queued_entry`'s
23//! cancel-then-send. The guard is
24//! [`omega_front_door::disposition`](omega_front_door::disposition), a total
25//! law over all three executor classes; this module records what it decided
26//! beside the item, so the reason a message was not steered outlives the
27//! session that decided it.
28//!
29//! ## Shape
30//!
31//! Same shape as [`crate::omega_router::RouteJournal`], for the same reasons:
32//! a `BTreeMap` so the file does not change when nothing decided differently,
33//! an atomic temporary-file rewrite so a crash leaves the previous journal
34//! rather than a truncated one, and typed round-tripping so a hand-edited file
35//! is refused rather than believed.
36//!
37//! One rule this journal has that the route journal does not: **a terminal item
38//! is never reopened.** `promoted`, `cancelled` and `failed` are final. A
39//! restart that could move an item back to `queued` would promote it a second
40//! time, which is exactly the duplication the acceptance criterion names.
41
42use std::cell::RefCell;
43use std::collections::BTreeMap;
44use std::path::{Path, PathBuf};
45
46use omega_front_door::{
47    ExecutorClass, QueueItemState, Quiescence, SendCommand, SendDisposition, SteerCapability,
48    disposition, may_promote,
49};
50use serde_json::Value;
51
52/// Schema of the durable queue document.
53pub const SEND_QUEUE_JOURNAL_SCHEMA: &str = "openagents.omega.agent_send_queue.v1";
54
55/// File the document lives in, under the Omega data directory.
56pub const SEND_QUEUE_JOURNAL_FILE: &str = "agent-send-queue.json";
57
58/// One admitted message, as it survives a restart.
59///
60/// The editor handle and the tracked buffers are deliberately absent: they are
61/// live GPUI values and cannot be a durable fact. What has to survive is the
62/// text the person wrote, where it was going, what they asked for, what Omega
63/// decided, and where the item is in its life.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct QueuedSend {
66    /// Stable across restarts and unique within a thread. Ordering identity as
67    /// well as name: promotion is by `(sequence, item_id)`, never by map order.
68    pub item_id: String,
69    pub thread_id: String,
70    pub sequence: u64,
71    /// The message body. Plain text only — a durable record of a message the
72    /// user typed, not a serialised content-block tree.
73    pub text: String,
74    pub command: SendCommand,
75    pub class: ExecutorClass,
76    pub capability: SteerCapability,
77    pub state: QueueItemState,
78}
79
80impl QueuedSend {
81    /// What Omega will do with this item, derived from the parts.
82    ///
83    /// Derived on every call and never stored, the same rule
84    /// `ExecutorDisclosure` holds to: a stored disposition could disagree with
85    /// the law that produced it, and then the record would be the lie.
86    #[must_use]
87    pub const fn disposition(&self) -> SendDisposition {
88        disposition(self.command, self.class, self.capability)
89    }
90
91    /// Whether this item may be promoted now.
92    #[must_use]
93    pub const fn may_promote(&self, quiescence: Quiescence) -> bool {
94        may_promote(self.state, quiescence)
95    }
96}
97
98/// Why an admission or a transition did not happen.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum SendQueueRefusal {
101    /// The item is already terminal. Promoting it again would duplicate a turn.
102    ItemIsTerminal,
103    /// No item with this id.
104    UnknownItem,
105    /// The prior turn is not proven finished.
106    NotQuiescent,
107    /// The record could not be written, so the user was not told it was queued.
108    NotPersisted,
109}
110
111impl SendQueueRefusal {
112    #[must_use]
113    pub const fn token(self) -> &'static str {
114        match self {
115            Self::ItemIsTerminal => "item_is_terminal",
116            Self::UnknownItem => "unknown_item",
117            Self::NotQuiescent => "not_quiescent",
118            Self::NotPersisted => "not_persisted",
119        }
120    }
121}
122
123/// The durable queue.
124///
125/// One journal for the whole app, partitioned by thread id. Promotion is the
126/// job of a single thread-owned scheduler, which asks
127/// [`head_for`](Self::head_for) and then [`promote`](Self::promote).
128pub struct SendQueueJournal {
129    path: PathBuf,
130    /// `BTreeMap` so the serialised file is a function of the items, not of
131    /// hash order — a journal that changes when nothing decided differently is
132    /// a journal nobody can diff.
133    items: RefCell<BTreeMap<String, QueuedSend>>,
134    next_sequence: RefCell<u64>,
135}
136
137impl SendQueueJournal {
138    /// The journal at the Omega data directory's usual place.
139    #[must_use]
140    pub fn at_data_dir() -> Self {
141        Self::at(
142            paths::data_dir()
143                .join("openagents")
144                .join(SEND_QUEUE_JOURNAL_FILE),
145        )
146    }
147
148    /// The journal at an explicit path. Loads what is already there.
149    ///
150    /// An unreadable journal starts empty **and says so loudly**. It does not
151    /// silently continue, because a queue that quietly forgot an admitted
152    /// message is the defect this module exists to prevent, and a warning is
153    /// the only honest thing available at this layer.
154    #[must_use]
155    pub fn at(path: PathBuf) -> Self {
156        let items = load(&path).unwrap_or_else(|error| {
157            log::warn!(
158                "OMEGA-DELTA-0032: send queue at {} could not be read ({error:#}); \
159                 starting from empty. Any admitted message it held is lost.",
160                path.display()
161            );
162            BTreeMap::new()
163        });
164        let next = items.values().map(|item| item.sequence).max().unwrap_or(0) + 1;
165        Self {
166            path,
167            items: RefCell::new(items),
168            next_sequence: RefCell::new(next),
169        }
170    }
171
172    /// Admit a message to the queue, durably, before acknowledging it.
173    ///
174    /// The `Ok` here is the acknowledgement the UI is allowed to show. A caller
175    /// that renders "queued" on the `Err` path has reintroduced the defect.
176    ///
177    /// # Errors
178    ///
179    /// [`SendQueueRefusal::NotPersisted`] when the record could not be written.
180    pub fn admit(
181        &self,
182        thread_id: &str,
183        item_id: &str,
184        text: &str,
185        command: SendCommand,
186        class: ExecutorClass,
187        capability: SteerCapability,
188    ) -> Result<QueuedSend, SendQueueRefusal> {
189        let sequence = {
190            let mut next = self.next_sequence.borrow_mut();
191            let sequence = *next;
192            *next += 1;
193            sequence
194        };
195        let item = QueuedSend {
196            item_id: item_id.to_owned(),
197            thread_id: thread_id.to_owned(),
198            sequence,
199            text: text.to_owned(),
200            command,
201            class,
202            capability,
203            state: QueueItemState::Queued,
204        };
205        self.items
206            .borrow_mut()
207            .insert(Self::key(thread_id, item_id), item.clone());
208        match self.persist() {
209            Ok(()) => Ok(item),
210            Err(error) => {
211                log::error!(
212                    "OMEGA-DELTA-0032: {item_id} could not be admitted to {}: {error:#}",
213                    self.path.display()
214                );
215                self.items
216                    .borrow_mut()
217                    .remove(&Self::key(thread_id, item_id));
218                Err(SendQueueRefusal::NotPersisted)
219            }
220        }
221    }
222
223    /// Every open item for a thread, in promotion order.
224    #[must_use]
225    pub fn open_items(&self, thread_id: &str) -> Vec<QueuedSend> {
226        let mut items: Vec<QueuedSend> = self
227            .items
228            .borrow()
229            .values()
230            .filter(|item| item.thread_id == thread_id && item.state.is_open())
231            .cloned()
232            .collect();
233        items.sort_by(|left, right| {
234            left.sequence
235                .cmp(&right.sequence)
236                .then_with(|| left.item_id.cmp(&right.item_id))
237        });
238        items
239    }
240
241    /// Every item for a thread, including terminal ones, in the same order.
242    #[must_use]
243    pub fn items(&self, thread_id: &str) -> Vec<QueuedSend> {
244        let mut items: Vec<QueuedSend> = self
245            .items
246            .borrow()
247            .values()
248            .filter(|item| item.thread_id == thread_id)
249            .cloned()
250            .collect();
251        items.sort_by(|left, right| {
252            left.sequence
253                .cmp(&right.sequence)
254                .then_with(|| left.item_id.cmp(&right.item_id))
255        });
256        items
257    }
258
259    /// The item the scheduler would promote next, if any.
260    #[must_use]
261    pub fn head_for(&self, thread_id: &str) -> Option<QueuedSend> {
262        self.open_items(thread_id).into_iter().next()
263    }
264
265    /// Promote the queue head, but only after proven quiescence.
266    ///
267    /// # Errors
268    ///
269    /// [`SendQueueRefusal::NotQuiescent`] when the prior turn is running or its
270    /// stop was never observed — the reconnect case, where promoting is how a
271    /// message gets sent twice.
272    pub fn promote(
273        &self,
274        thread_id: &str,
275        item_id: &str,
276        quiescence: Quiescence,
277    ) -> Result<QueuedSend, SendQueueRefusal> {
278        let key = Self::key(thread_id, item_id);
279        let current = self
280            .items
281            .borrow()
282            .get(&key)
283            .cloned()
284            .ok_or(SendQueueRefusal::UnknownItem)?;
285        if !current.state.is_open() {
286            return Err(SendQueueRefusal::ItemIsTerminal);
287        }
288        if !may_promote(current.state, quiescence) {
289            return Err(SendQueueRefusal::NotQuiescent);
290        }
291        self.transition(&key, QueueItemState::Promoted)
292    }
293
294    /// Withdraw an item before it is promoted.
295    ///
296    /// # Errors
297    ///
298    /// [`SendQueueRefusal::ItemIsTerminal`] for an item that already ended.
299    pub fn cancel(&self, thread_id: &str, item_id: &str) -> Result<QueuedSend, SendQueueRefusal> {
300        let key = Self::key(thread_id, item_id);
301        self.require_open(&key)?;
302        self.transition(&key, QueueItemState::Cancelled)
303    }
304
305    /// Record that promotion was attempted and did not start a turn.
306    ///
307    /// # Errors
308    ///
309    /// [`SendQueueRefusal::ItemIsTerminal`] for an item that already ended.
310    pub fn fail(&self, thread_id: &str, item_id: &str) -> Result<QueuedSend, SendQueueRefusal> {
311        let key = Self::key(thread_id, item_id);
312        self.require_open(&key)?;
313        self.transition(&key, QueueItemState::Failed)
314    }
315
316    fn require_open(&self, key: &str) -> Result<(), SendQueueRefusal> {
317        let items = self.items.borrow();
318        let item = items.get(key).ok_or(SendQueueRefusal::UnknownItem)?;
319        if item.state.is_open() {
320            Ok(())
321        } else {
322            Err(SendQueueRefusal::ItemIsTerminal)
323        }
324    }
325
326    fn transition(
327        &self,
328        key: &str,
329        state: QueueItemState,
330    ) -> Result<QueuedSend, SendQueueRefusal> {
331        let updated = {
332            let mut items = self.items.borrow_mut();
333            let item = items.get_mut(key).ok_or(SendQueueRefusal::UnknownItem)?;
334            item.state = state;
335            item.clone()
336        };
337        match self.persist() {
338            Ok(()) => Ok(updated),
339            Err(error) => {
340                log::error!(
341                    "OMEGA-DELTA-0032: {key} could not record {}: {error:#}",
342                    state.token()
343                );
344                Err(SendQueueRefusal::NotPersisted)
345            }
346        }
347    }
348
349    fn key(thread_id: &str, item_id: &str) -> String {
350        format!("{thread_id}\u{1f}{item_id}")
351    }
352
353    fn persist(&self) -> anyhow::Result<()> {
354        let items = self.items.borrow();
355        let document = serde_json::json!({
356            "schema": SEND_QUEUE_JOURNAL_SCHEMA,
357            "items": items
358                .values()
359                .map(|item| serde_json::json!({
360                    "itemId": item.item_id,
361                    "threadId": item.thread_id,
362                    "sequence": item.sequence,
363                    "text": item.text,
364                    "command": item.command.token(),
365                    "class": item.class.token(),
366                    "capability": item.capability.token(),
367                    "state": item.state.token(),
368                }))
369                .collect::<Vec<_>>(),
370        });
371        if let Some(parent) = self.path.parent() {
372            std::fs::create_dir_all(parent)?;
373        }
374        let temporary = self.path.with_extension("json.tmp");
375        std::fs::write(&temporary, serde_json::to_vec_pretty(&document)?)?;
376        std::fs::rename(&temporary, &self.path)?;
377        Ok(())
378    }
379}
380
381fn parse_class(token: &str) -> Option<ExecutorClass> {
382    ExecutorClass::all()
383        .iter()
384        .copied()
385        .find(|class| class.token() == token)
386}
387
388fn parse_capability(token: &str) -> Option<SteerCapability> {
389    SteerCapability::all()
390        .iter()
391        .copied()
392        .find(|capability| capability.token() == token)
393}
394
395fn load(path: &Path) -> anyhow::Result<BTreeMap<String, QueuedSend>> {
396    if !path.exists() {
397        return Ok(BTreeMap::new());
398    }
399    let document: Value = serde_json::from_slice(&std::fs::read(path)?)?;
400    let schema = document.get("schema").and_then(Value::as_str);
401    anyhow::ensure!(
402        schema == Some(SEND_QUEUE_JOURNAL_SCHEMA),
403        "unsupported send queue schema {schema:?}"
404    );
405    let mut items = BTreeMap::new();
406    for entry in document
407        .get("items")
408        .and_then(Value::as_array)
409        .unwrap_or(&Vec::new())
410    {
411        let (
412            Some(item_id),
413            Some(thread_id),
414            Some(sequence),
415            Some(text),
416            Some(command),
417            Some(class),
418            Some(capability),
419            Some(state),
420        ) = (
421            entry.get("itemId").and_then(Value::as_str),
422            entry.get("threadId").and_then(Value::as_str),
423            entry.get("sequence").and_then(Value::as_u64),
424            entry.get("text").and_then(Value::as_str),
425            entry
426                .get("command")
427                .and_then(Value::as_str)
428                .and_then(SendCommand::parse_token),
429            entry
430                .get("class")
431                .and_then(Value::as_str)
432                .and_then(parse_class),
433            entry
434                .get("capability")
435                .and_then(Value::as_str)
436                .and_then(parse_capability),
437            entry
438                .get("state")
439                .and_then(Value::as_str)
440                .and_then(QueueItemState::parse_token),
441        )
442        else {
443            anyhow::bail!("send queue entry is not a complete queued send");
444        };
445        items.insert(
446            SendQueueJournal::key(thread_id, item_id),
447            QueuedSend {
448                item_id: item_id.to_owned(),
449                thread_id: thread_id.to_owned(),
450                sequence,
451                text: text.to_owned(),
452                command,
453                class,
454                capability,
455                state,
456            },
457        );
458    }
459    Ok(items)
460}
461
462/// The line the composer shows for one admitted item.
463///
464/// Derived from the item, so the queue cannot render a promise its disposition
465/// does not support. Nothing stores this.
466#[must_use]
467pub fn queue_item_phrase(item: &QueuedSend) -> String {
468    match item.state {
469        QueueItemState::Queued => item.disposition().phrase(),
470        QueueItemState::Promoted => "Sent.".to_owned(),
471        QueueItemState::Cancelled => "Removed from the queue.".to_owned(),
472        QueueItemState::Failed => "Could not be sent. Still in the queue history.".to_owned(),
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use omega_front_door::{SendFallback, SteerRefusal};
480
481    fn journal() -> (SendQueueJournal, tempfile::TempDir) {
482        let dir = tempfile::tempdir().expect("temp dir");
483        let path = dir.path().join(SEND_QUEUE_JOURNAL_FILE);
484        (SendQueueJournal::at(path), dir)
485    }
486
487    fn admit(
488        journal: &SendQueueJournal,
489        item_id: &str,
490        command: SendCommand,
491        class: ExecutorClass,
492        capability: SteerCapability,
493    ) -> QueuedSend {
494        journal
495            .admit("thread-1", item_id, "look at the other file too", command, class, capability)
496            .expect("admitted")
497    }
498
499    /// The falsifier's second half: "queue state lives only in renderer
500    /// memory". A journal reopened from the same path is the restart.
501    #[test]
502    fn an_admitted_message_survives_a_restart() {
503        let dir = tempfile::tempdir().expect("temp dir");
504        let path = dir.path().join(SEND_QUEUE_JOURNAL_FILE);
505        {
506            let journal = SendQueueJournal::at(path.clone());
507            journal
508                .admit(
509                    "thread-1",
510                    "item-1",
511                    "and check the tests",
512                    SendCommand::Enqueue,
513                    ExecutorClass::NativeLoop,
514                    SteerCapability::Unknown,
515                )
516                .expect("admitted");
517        }
518        let reopened = SendQueueJournal::at(path);
519        let items = reopened.open_items("thread-1");
520        assert_eq!(items.len(), 1);
521        assert_eq!(items[0].text, "and check the tests");
522        assert_eq!(items[0].state, QueueItemState::Queued);
523    }
524
525    /// A promoted item that a restart reopened would be sent twice. The
526    /// acceptance criterion names this exact case.
527    #[test]
528    fn a_promoted_item_is_not_reopened_by_a_restart_and_cannot_promote_twice() {
529        let dir = tempfile::tempdir().expect("temp dir");
530        let path = dir.path().join(SEND_QUEUE_JOURNAL_FILE);
531        {
532            let journal = SendQueueJournal::at(path.clone());
533            admit(
534                &journal,
535                "item-1",
536                SendCommand::Enqueue,
537                ExecutorClass::NativeLoop,
538                SteerCapability::Unknown,
539            );
540            journal
541                .promote("thread-1", "item-1", Quiescence::Proven)
542                .expect("promoted once");
543        }
544        let reopened = SendQueueJournal::at(path);
545        assert!(reopened.open_items("thread-1").is_empty());
546        assert_eq!(reopened.items("thread-1").len(), 1);
547        assert_eq!(
548            reopened.promote("thread-1", "item-1", Quiescence::Proven),
549            Err(SendQueueRefusal::ItemIsTerminal)
550        );
551    }
552
553    /// After a reconnect Omega never saw the prior turn stop. Promoting there
554    /// is how a queued message races the turn it was meant to follow.
555    #[test]
556    fn a_reconnect_does_not_promote_on_an_unobserved_stop() {
557        let (journal, _dir) = journal();
558        admit(
559            &journal,
560            "item-1",
561            SendCommand::Enqueue,
562            ExecutorClass::ExternalAcp,
563            SteerCapability::Unknown,
564        );
565        assert_eq!(
566            journal.promote("thread-1", "item-1", Quiescence::Unknown),
567            Err(SendQueueRefusal::NotQuiescent)
568        );
569        assert_eq!(
570            journal.promote("thread-1", "item-1", Quiescence::Running),
571            Err(SendQueueRefusal::NotQuiescent)
572        );
573        assert!(journal.promote("thread-1", "item-1", Quiescence::Proven).is_ok());
574    }
575
576    /// Ordering is the item's own, not the map's. Two items admitted in order
577    /// promote in that order however their ids sort.
578    #[test]
579    fn promotion_order_is_admission_order_not_identifier_order() {
580        let (journal, _dir) = journal();
581        admit(
582            &journal,
583            "zzz-first",
584            SendCommand::Enqueue,
585            ExecutorClass::NativeLoop,
586            SteerCapability::Unknown,
587        );
588        admit(
589            &journal,
590            "aaa-second",
591            SendCommand::Enqueue,
592            ExecutorClass::NativeLoop,
593            SteerCapability::Unknown,
594        );
595        assert_eq!(journal.head_for("thread-1").expect("head").item_id, "zzz-first");
596    }
597
598    /// Two threads, one file, no crossing. A queue that leaked between threads
599    /// would deliver somebody's message to the wrong agent.
600    #[test]
601    fn items_do_not_cross_between_threads() {
602        let (journal, _dir) = journal();
603        admit(
604            &journal,
605            "item-1",
606            SendCommand::Enqueue,
607            ExecutorClass::NativeLoop,
608            SteerCapability::Unknown,
609        );
610        journal
611            .admit(
612                "thread-2",
613                "item-1",
614                "different thread, same item id",
615                SendCommand::Enqueue,
616                ExecutorClass::ExternalAcp,
617                SteerCapability::CanSteer,
618            )
619            .expect("admitted");
620        assert_eq!(journal.open_items("thread-1").len(), 1);
621        assert_eq!(journal.open_items("thread-2").len(), 1);
622        assert_eq!(
623            journal.open_items("thread-2")[0].text,
624            "different thread, same item id"
625        );
626    }
627
628    /// The disposition is derived from the stored parts on every read, so a
629    /// restored item cannot claim an outcome the law does not give it.
630    #[test]
631    fn a_restored_item_re_derives_its_disposition_rather_than_replaying_one() {
632        let dir = tempfile::tempdir().expect("temp dir");
633        let path = dir.path().join(SEND_QUEUE_JOURNAL_FILE);
634        {
635            let journal = SendQueueJournal::at(path.clone());
636            admit(
637                &journal,
638                "item-1",
639                SendCommand::Steer,
640                ExecutorClass::EngineLane,
641                SteerCapability::CanSteer,
642            );
643        }
644        let reopened = SendQueueJournal::at(path.clone());
645        let item = reopened.head_for("thread-1").expect("head");
646        assert_eq!(
647            item.disposition(),
648            SendDisposition::Refused {
649                refusal: SteerRefusal::EngineLaneIsRunAuthority,
650                fallback: SendFallback::HeldUntilQuiescent,
651            }
652        );
653        assert!(!item.disposition().reaches_running_turn());
654        // And the file itself holds no rendered disposition to disagree with.
655        let raw = std::fs::read_to_string(&path).expect("readable");
656        assert!(!raw.contains("held_until_quiescent"));
657        assert!(!raw.contains("Queued:"));
658    }
659
660    /// A journal it cannot read starts empty rather than pretending. The
661    /// schema check is what stops it adopting somebody else's file.
662    #[test]
663    fn a_foreign_document_is_refused_rather_than_adopted() {
664        let dir = tempfile::tempdir().expect("temp dir");
665        let path = dir.path().join(SEND_QUEUE_JOURNAL_FILE);
666        std::fs::write(
667            &path,
668            serde_json::to_vec_pretty(&serde_json::json!({
669                "schema": "openagents.omega.agent_route_journal.v1",
670                "items": [],
671            }))
672            .expect("json"),
673        )
674        .expect("written");
675        assert!(load(&path).is_err());
676        assert!(SendQueueJournal::at(path).open_items("thread-1").is_empty());
677    }
678
679    #[test]
680    fn a_cancelled_item_leaves_the_queue_and_stays_gone() {
681        let (journal, _dir) = journal();
682        admit(
683            &journal,
684            "item-1",
685            SendCommand::Enqueue,
686            ExecutorClass::NativeLoop,
687            SteerCapability::Unknown,
688        );
689        assert!(journal.cancel("thread-1", "item-1").is_ok());
690        assert!(journal.open_items("thread-1").is_empty());
691        assert_eq!(
692            journal.promote("thread-1", "item-1", Quiescence::Proven),
693            Err(SendQueueRefusal::ItemIsTerminal)
694        );
695        assert_eq!(
696            journal.cancel("thread-1", "item-1"),
697            Err(SendQueueRefusal::ItemIsTerminal)
698        );
699    }
700
701    /// Every state a queued item can be in says something different to the
702    /// person who queued it. A shared phrase would make two of them
703    /// indistinguishable in the composer.
704    #[test]
705    fn every_queue_state_says_something_a_reader_can_tell_apart() {
706        let (journal, _dir) = journal();
707        let mut phrases = Vec::new();
708        for (index, state) in QueueItemState::all().iter().enumerate() {
709            let item_id = format!("item-{index}");
710            let mut item = admit(
711                &journal,
712                &item_id,
713                SendCommand::Enqueue,
714                ExecutorClass::NativeLoop,
715                SteerCapability::Unknown,
716            );
717            item.state = *state;
718            phrases.push(queue_item_phrase(&item));
719        }
720        let unique: std::collections::BTreeSet<_> = phrases.iter().collect();
721        assert_eq!(unique.len(), phrases.len(), "{phrases:?} are not distinct");
722    }
723
724    /// The two commands are not the same command with a flag. An enqueue never
725    /// reaches the running turn on any class, and the record says which was
726    /// asked for.
727    #[test]
728    fn steer_and_enqueue_are_recorded_as_different_commands() {
729        let (journal, _dir) = journal();
730        let steered = admit(
731            &journal,
732            "item-steer",
733            SendCommand::Steer,
734            ExecutorClass::NativeLoop,
735            SteerCapability::Unknown,
736        );
737        let queued = admit(
738            &journal,
739            "item-queue",
740            SendCommand::Enqueue,
741            ExecutorClass::NativeLoop,
742            SteerCapability::Unknown,
743        );
744        assert_ne!(steered.command, queued.command);
745        assert!(steered.disposition().reaches_running_turn());
746        assert!(!queued.disposition().reaches_running_turn());
747    }
748}
749
Served at tenant.openagents/omega Member data and write actions are omitted.