Skip to main content

lash_core/runtime/
turn_queue.rs

1use super::process::ProcessWakeDelivery;
2use crate::{PluginMessage, TurnCause, TurnInput};
3
4#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
5#[serde(tag = "kind", rename_all = "snake_case")]
6pub enum SessionCommand {
7    // No generation guard: the command drains asynchronously, so any
8    // generation observed at enqueue time may legitimately have advanced by
9    // drain time, and the refresh recomputes the surface from live sources
10    // regardless — a guard could only fail spuriously.
11    RefreshToolCatalog { reason: String },
12    ResetSession { reason: String },
13}
14
15impl SessionCommand {
16    pub fn kind(&self) -> &'static str {
17        match self {
18            Self::RefreshToolCatalog { .. } => "refresh_tool_catalog",
19            Self::ResetSession { .. } => "reset_session",
20        }
21    }
22
23    pub fn source_key(&self, idempotency_key: impl AsRef<str>) -> String {
24        format!("command:{}:{}", self.kind(), idempotency_key.as_ref())
25    }
26}
27
28#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
29pub struct SessionCommandReceipt {
30    pub session_id: String,
31    pub batch_id: String,
32    pub source_key: String,
33}
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum DeliveryPolicy {
38    EarliestSafeBoundary,
39    AfterCurrentTurnCommit,
40}
41
42impl DeliveryPolicy {
43    pub fn as_str(self) -> &'static str {
44        match self {
45            Self::EarliestSafeBoundary => "earliest_safe_boundary",
46            Self::AfterCurrentTurnCommit => "after_current_turn_commit",
47        }
48    }
49
50    pub fn from_wire_str(value: &str) -> Option<Self> {
51        match value {
52            "earliest_safe_boundary" => Some(Self::EarliestSafeBoundary),
53            "after_current_turn_commit" => Some(Self::AfterCurrentTurnCommit),
54            _ => None,
55        }
56    }
57}
58
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum SlotPolicy {
62    Join,
63    Exclusive,
64}
65
66impl SlotPolicy {
67    pub fn as_str(self) -> &'static str {
68        match self {
69            Self::Join => "join",
70            Self::Exclusive => "exclusive",
71        }
72    }
73
74    pub fn from_wire_str(value: &str) -> Option<Self> {
75        match value {
76            "join" => Some(Self::Join),
77            "exclusive" => Some(Self::Exclusive),
78            _ => None,
79        }
80    }
81}
82
83#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum MergeKey {
86    Never,
87    PayloadDefault,
88    Group(String),
89}
90
91#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
92#[serde(tag = "type", rename_all = "snake_case")]
93pub enum QueuedWorkPayload {
94    ProcessWake {
95        wake: Box<ProcessWakeDelivery>,
96    },
97    AgentFrameTask {
98        frame_id: crate::AgentFrameId,
99        task: String,
100        #[serde(default, skip_serializing_if = "Option::is_none")]
101        protocol_turn_options: Option<crate::ProtocolTurnOptions>,
102    },
103    SessionCommand {
104        command: Box<SessionCommand>,
105    },
106}
107
108#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub enum QueuedWorkClass {
111    SessionCommand,
112    TurnWork,
113}
114
115impl QueuedWorkPayload {
116    pub fn process_wake(wake: ProcessWakeDelivery) -> Self {
117        Self::ProcessWake {
118            wake: Box::new(wake),
119        }
120    }
121
122    pub fn session_command(command: SessionCommand) -> Self {
123        Self::SessionCommand {
124            command: Box::new(command),
125        }
126    }
127
128    pub fn agent_frame_task(
129        frame_id: impl Into<crate::AgentFrameId>,
130        task: impl Into<String>,
131        protocol_turn_options: Option<crate::ProtocolTurnOptions>,
132    ) -> Self {
133        Self::AgentFrameTask {
134            frame_id: frame_id.into(),
135            task: task.into(),
136            protocol_turn_options,
137        }
138    }
139
140    pub fn work_class(&self) -> QueuedWorkClass {
141        match self {
142            Self::SessionCommand { .. } => QueuedWorkClass::SessionCommand,
143            Self::ProcessWake { .. } | Self::AgentFrameTask { .. } => QueuedWorkClass::TurnWork,
144        }
145    }
146}
147
148#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
149pub struct QueuedWorkItem {
150    pub item_id: String,
151    pub payload: QueuedWorkPayload,
152}
153
154#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
155pub struct QueuedWorkBatch {
156    pub batch_id: String,
157    pub session_id: String,
158    pub enqueue_seq: u64,
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub source_key: Option<String>,
161    pub delivery_policy: DeliveryPolicy,
162    pub slot_policy: SlotPolicy,
163    pub merge_key: MergeKey,
164    pub available_at_ms: u64,
165    pub enqueued_at_ms: u64,
166    pub items: Vec<QueuedWorkItem>,
167}
168
169impl QueuedWorkBatch {
170    pub fn work_class(&self) -> Option<QueuedWorkClass> {
171        work_class_for_payloads(self.items.iter().map(|item| &item.payload))
172    }
173
174    pub fn is_session_command_work(&self) -> bool {
175        self.work_class() == Some(QueuedWorkClass::SessionCommand)
176    }
177
178    pub fn is_turn_work(&self) -> bool {
179        self.work_class() == Some(QueuedWorkClass::TurnWork)
180    }
181}
182
183#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
184pub struct QueuedWorkBatchDraft {
185    pub session_id: String,
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub source_key: Option<String>,
188    pub delivery_policy: DeliveryPolicy,
189    pub slot_policy: SlotPolicy,
190    pub merge_key: MergeKey,
191    pub available_at_ms: u64,
192    pub payloads: Vec<QueuedWorkPayload>,
193}
194
195impl QueuedWorkBatchDraft {
196    pub fn new(
197        session_id: impl Into<String>,
198        delivery_policy: DeliveryPolicy,
199        slot_policy: SlotPolicy,
200        payloads: impl Into<Vec<QueuedWorkPayload>>,
201    ) -> Self {
202        Self {
203            session_id: session_id.into(),
204            source_key: None,
205            delivery_policy,
206            slot_policy,
207            merge_key: MergeKey::Never,
208            available_at_ms: 0,
209            payloads: payloads.into(),
210        }
211    }
212
213    pub fn with_source_key(mut self, source_key: impl Into<String>) -> Self {
214        self.source_key = Some(source_key.into());
215        self
216    }
217
218    pub fn with_available_at_ms(mut self, available_at_ms: u64) -> Self {
219        self.available_at_ms = available_at_ms;
220        self
221    }
222
223    pub fn with_merge_key(mut self, merge_key: MergeKey) -> Self {
224        self.merge_key = merge_key;
225        self
226    }
227
228    pub fn work_class(&self) -> Option<QueuedWorkClass> {
229        work_class_for_payloads(self.payloads.iter())
230    }
231}
232
233fn work_class_for_payloads<'a>(
234    payloads: impl IntoIterator<Item = &'a QueuedWorkPayload>,
235) -> Option<QueuedWorkClass> {
236    let mut payloads = payloads.into_iter();
237    let first = payloads.next()?.work_class();
238    payloads
239        .all(|payload| payload.work_class() == first)
240        .then_some(first)
241}
242
243#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
244#[serde(rename_all = "snake_case")]
245pub enum QueuedWorkClaimBoundary {
246    ActiveTurnCheckpoint,
247    Idle,
248}
249
250#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
251pub struct QueuedWorkCompletion {
252    pub session_id: String,
253    pub claim_id: String,
254    pub lease_token: String,
255    pub batch_ids: Vec<String>,
256}
257
258#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
259pub struct QueuedWorkClaim {
260    pub session_id: String,
261    pub claim_id: String,
262    pub owner: crate::LeaseOwnerIdentity,
263    pub lease_token: String,
264    pub fencing_token: u64,
265    /// The session-execution-lease generation this claim pins. The claim is
266    /// live exactly while this generation still holds the session lease
267    /// (ADR 0029).
268    pub session_lease_generation: u64,
269    pub batches: Vec<QueuedWorkBatch>,
270}
271
272impl QueuedWorkClaim {
273    pub fn completion(&self) -> QueuedWorkCompletion {
274        QueuedWorkCompletion {
275            session_id: self.session_id.clone(),
276            claim_id: self.claim_id.clone(),
277            lease_token: self.lease_token.clone(),
278            batch_ids: self
279                .batches
280                .iter()
281                .map(|batch| batch.batch_id.clone())
282                .collect(),
283        }
284    }
285
286    pub fn is_empty(&self) -> bool {
287        self.batches.iter().all(|batch| batch.items.is_empty())
288    }
289
290    pub fn materialize_for_checkpoint(&self) -> QueuedCheckpointWork {
291        let messages = Vec::new();
292        let transient_messages = Vec::new();
293        let mut turn_causes = Vec::new();
294        for batch in &self.batches {
295            for item in &batch.items {
296                match &item.payload {
297                    QueuedWorkPayload::ProcessWake { wake } => {
298                        turn_causes.push(crate::process_wake_turn_cause(wake));
299                    }
300                    QueuedWorkPayload::AgentFrameTask { .. } => {}
301                    QueuedWorkPayload::SessionCommand { .. } => {}
302                }
303            }
304        }
305        QueuedCheckpointWork {
306            messages,
307            transient_messages,
308            turn_causes,
309        }
310    }
311
312    pub async fn materialize_for_checkpoint_with_attachments(
313        &self,
314        _attachment_store: &crate::SessionAttachmentStore,
315    ) -> Result<QueuedCheckpointWork, String> {
316        let messages = Vec::new();
317        let transient_messages = Vec::new();
318        let mut turn_causes = Vec::new();
319        for batch in &self.batches {
320            for item in &batch.items {
321                match &item.payload {
322                    QueuedWorkPayload::ProcessWake { wake } => {
323                        turn_causes.push(crate::process_wake_turn_cause(wake));
324                    }
325                    QueuedWorkPayload::AgentFrameTask { .. } => {}
326                    QueuedWorkPayload::SessionCommand { .. } => {}
327                }
328            }
329        }
330        Ok(QueuedCheckpointWork {
331            messages,
332            transient_messages,
333            turn_causes,
334        })
335    }
336
337    pub fn exclusive_session_command(&self) -> Option<(&QueuedWorkBatch, &SessionCommand)> {
338        if self.batches.len() != 1 {
339            return None;
340        }
341        let batch = self.batches.first()?;
342        if batch.slot_policy != SlotPolicy::Exclusive || batch.items.len() != 1 {
343            return None;
344        }
345        let item = batch.items.first()?;
346        match &item.payload {
347            QueuedWorkPayload::SessionCommand { command } => Some((batch, command.as_ref())),
348            _ => None,
349        }
350    }
351
352    pub fn materialize_for_turn(&self) -> QueuedTurnWork {
353        let checkpoint = self.materialize_for_checkpoint();
354        let mut input = TurnInput::empty();
355        for batch in &self.batches {
356            for item in &batch.items {
357                if let QueuedWorkPayload::AgentFrameTask {
358                    task,
359                    protocol_turn_options,
360                    ..
361                } = &item.payload
362                {
363                    input = TurnInput::text(task.clone());
364                    input.protocol_turn_options = protocol_turn_options.clone();
365                }
366            }
367        }
368        QueuedTurnWork {
369            input,
370            messages: checkpoint.messages,
371            turn_causes: checkpoint.turn_causes,
372        }
373    }
374}
375
376#[derive(Clone, Debug, Default)]
377pub struct QueuedCheckpointWork {
378    pub messages: Vec<PluginMessage>,
379    pub transient_messages: Vec<PluginMessage>,
380    pub turn_causes: Vec<TurnCause>,
381}
382
383#[derive(Clone, Debug)]
384pub struct QueuedTurnWork {
385    pub input: TurnInput,
386    pub messages: Vec<PluginMessage>,
387    pub turn_causes: Vec<TurnCause>,
388}
389
390pub fn process_wake_batch_draft(wake: ProcessWakeDelivery) -> QueuedWorkBatchDraft {
391    let source_key = format!("process:{}:event:{}:wake", wake.process_id, wake.sequence);
392    QueuedWorkBatchDraft::new(
393        wake.target_session_id.clone(),
394        DeliveryPolicy::EarliestSafeBoundary,
395        SlotPolicy::Exclusive,
396        vec![QueuedWorkPayload::process_wake(wake)],
397    )
398    .with_source_key(source_key)
399}