Skip to main content

forge_workspace/
parked.rs

1//! Payloads that arrived while their slot had no live session, addressed
2//! by `(org, project, label)` - the triple the `sessions` table is keyed
3//! by.
4
5use std::collections::HashMap;
6use std::sync::Arc;
7
8use crate::SessionSlot;
9use crate::mcp::gotify::types::GotifyNotification;
10use crate::mcp::peers::types::{PeerFailureReason, WrappedPrompt};
11
12/// Everything parked for one slot, in the order a connecting session
13/// delivers it.
14#[derive(Default)]
15pub(crate) struct ParkedForSlot {
16    pub peer: Vec<ParkedPeer>,
17    /// Prompts held because the slot's session was mid-turn when they
18    /// arrived, drained at that turn's end rather than at a connect.
19    pub turn_held: Vec<ParkedPeer>,
20    pub cron: Vec<crate::crons::PendingCron>,
21    pub gotify: Vec<GotifyNotification>,
22    pub slack: Vec<forge_primitives::slack::SlackMessage>,
23}
24
25/// One peer message waiting for its slot to connect, or held for its
26/// turn to end, with the seat that sent it.
27///
28/// The sender rides here for the DELIVERY ACK alone: if the spawn this
29/// waited on fails, the bucket is dropped and the sender is told. It is
30/// not outstanding-ask tracking - nothing looks an entry up by id, and
31/// nothing here survives the delivery.
32pub(crate) struct ParkedPeer {
33    pub(crate) sender: SessionSlot,
34    pub(crate) wrapped: WrappedPrompt,
35}
36
37/// Parked payloads, one bucket per slot.
38pub(crate) type ParkedMap = HashMap<SessionSlot, ParkedForSlot>;
39
40impl crate::Workspace {
41    /// Park a peer prompt for `slot`, drained by the session that next
42    /// connects as that slot.
43    pub(crate) fn park_peer_prompt(
44        &self,
45        slot: &SessionSlot,
46        sender: &SessionSlot,
47        wrapped: WrappedPrompt,
48    ) {
49        self.parked_by_slot
50            .lock()
51            .entry(slot.clone())
52            .or_default()
53            .peer
54            .push(ParkedPeer { sender: sender.clone(), wrapped });
55    }
56
57    /// Whether `slot`'s bucket still holds the peer prompt with `id`. The
58    /// wake path parks before it dispatches a spawn, so an entry the spawn's
59    /// own failure expired means the delivery was not accepted.
60    pub(crate) fn peer_prompt_is_parked(
61        &self,
62        slot: &SessionSlot,
63        id: &crate::mcp::peers::types::MessageId,
64    ) -> bool {
65        self.parked_by_slot
66            .lock()
67            .get(slot)
68            .is_some_and(|bucket| bucket.peer.iter().any(|parked| parked.wrapped.id == *id))
69    }
70
71    /// Park a fired cron prompt for `slot`, missed-marked when it came
72    /// due while the owner was asleep. The entry's identity rides along so
73    /// the drained echo can name the schedule it came from.
74    pub(crate) fn park_cron(
75        &self,
76        slot: &SessionSlot,
77        cron: &forge_primitives::CronEntry,
78        missed: bool,
79    ) {
80        self.parked_by_slot.lock().entry(slot.clone()).or_default().cron.push(
81            crate::crons::PendingCron {
82                text: cron.prompt.clone(),
83                missed,
84                cron_id: cron.id.as_str().to_owned(),
85                description: cron.description.clone(),
86            },
87        );
88    }
89
90    /// Park a Gotify notification for `slot`.
91    pub(crate) fn park_gotify(&self, slot: &SessionSlot, notification: GotifyNotification) {
92        self.parked_by_slot.lock().entry(slot.clone()).or_default().gotify.push(notification);
93    }
94
95    /// Park a Slack batch for `slot`.
96    pub(crate) fn park_slack(
97        &self,
98        slot: &SessionSlot,
99        messages: Vec<forge_primitives::slack::SlackMessage>,
100    ) {
101        self.parked_by_slot.lock().entry(slot.clone()).or_default().slack.extend(messages);
102    }
103
104    /// Hold a peer prompt for `slot` until the turn it arrived under ends,
105    /// when the seat is idle again and the prompt can start a turn of its
106    /// own - unless the turn has already ended, in which case the prompt
107    /// comes back for delivery now.
108    ///
109    /// `turn_running` is read while the bucket's own lock is held, and the
110    /// turn-end drain ends the turn's markers before it takes the bucket:
111    /// the take's lock is what orders the two, so a delivery is either
112    /// enqueued before a drain (and delivered by it) or reads the ended turn
113    /// after that drain's take (and is delivered by its caller) - never left
114    /// behind a drain that already passed.
115    pub(crate) fn hold_peer_prompt_unless_turn_ended(
116        &self,
117        slot: &SessionSlot,
118        sender: &SessionSlot,
119        wrapped: WrappedPrompt,
120        turn_running: impl FnOnce() -> bool,
121    ) -> Option<WrappedPrompt> {
122        let mut map = self.parked_by_slot.lock();
123        if !turn_running() {
124            return Some(wrapped);
125        }
126        map.entry(slot.clone())
127            .or_default()
128            .turn_held
129            .push(ParkedPeer { sender: sender.clone(), wrapped });
130        None
131    }
132
133    /// Take (and clear) the prompts held for `slot`'s running turn, in
134    /// the order they arrived.
135    pub(crate) fn take_turn_held_for_slot(&self, slot: &SessionSlot) -> Vec<ParkedPeer> {
136        let mut map = self.parked_by_slot.lock();
137        let Some(bucket) = map.get_mut(slot) else { return Vec::new() };
138        std::mem::take(&mut bucket.turn_held)
139    }
140
141    /// Take (and clear) everything parked for the connecting session's
142    /// slot. The caller passes the triple it was spawned with, rather
143    /// than resolving one from its cwd: a cwd under no configured
144    /// project would resolve to nothing and leave the payloads parked
145    /// forever, silently.
146    ///
147    /// A prompt held for a turn end rides the peer list: the slot that
148    /// connected is a different, idle session, and dropping the hold with
149    /// the bucket would lose it silently.
150    pub(crate) fn take_parked_for_slot(&self, slot: &SessionSlot) -> ParkedForSlot {
151        let mut taken = self.parked_by_slot.lock().remove(slot).unwrap_or_default();
152        taken.peer.append(&mut taken.turn_held);
153        taken
154    }
155
156    /// Drop everything parked for `slot`, acknowledging each peer message
157    /// back to its sender so a message that never landed is not left
158    /// looking delivered. The parked message is the only payload with a
159    /// sender, so the Gotify and Slack drops have no one to tell and are
160    /// logged instead.
161    pub(crate) fn expire_parked_for_slot(
162        self: &Arc<Self>,
163        slot: &SessionSlot,
164        reason: PeerFailureReason,
165    ) {
166        let Some(parked) = self.parked_by_slot.lock().remove(slot) else { return };
167        for entry in parked.peer.into_iter().chain(parked.turn_held) {
168            self.notice_undelivered_message(&entry.sender, slot, reason);
169        }
170        for notification in parked.gotify {
171            tracing::warn!(
172                target: "forge_workspace::spawn",
173                org = %slot.org(),
174                project = %slot.project(),
175                label = %slot.label(),
176                app = %notification.app,
177                title = %notification.title,
178                "gotify notification dropped: the spawn it was parked for failed",
179            );
180        }
181        for message in parked.slack {
182            tracing::warn!(
183                target: "forge_workspace::spawn",
184                org = %slot.org(),
185                project = %slot.project(),
186                label = %slot.label(),
187                conversation = %message.conversation,
188                ts = %message.ts,
189                "slack message dropped: the spawn it was parked for failed",
190            );
191        }
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::ParkedForSlot;
198    use crate::SessionSlot;
199    use crate::mcp::peers::types::{MessageId, PeerFailureReason, WrappedKind, WrappedPrompt};
200    use crate::protocol::SessionUpdate;
201
202    fn slot(label: Option<&str>) -> SessionSlot {
203        SessionSlot::for_label("TestOrg", "parked-proj", label)
204    }
205
206    fn sender() -> SessionSlot {
207        SessionSlot::for_label("TestOrg", "sender-proj", None)
208    }
209
210    fn wrapped(id: &MessageId, body: &str) -> WrappedPrompt {
211        WrappedPrompt {
212            id: id.clone(),
213            kind: WrappedKind::Message,
214            sender_name: "forge".to_owned(),
215            sender_org: "Personal".to_owned(),
216            body: body.to_owned(),
217        }
218    }
219
220    /// A cron entry carrying what a park reads: the id, the prompt and a
221    /// description.
222    fn cron(id: &str, prompt: &str) -> forge_primitives::cron::CronEntry {
223        use forge_primitives::cron::{CronEntry, CronId, CronKind};
224        CronEntry {
225            id: CronId::from(id),
226            project_name: "parked-proj".to_owned(),
227            kind: CronKind::Recurring("0 9 * * *".to_owned()),
228            prompt: prompt.to_owned(),
229            created_at: std::time::SystemTime::UNIX_EPOCH,
230            description: Some(format!("{id} summary")),
231            last_fire: None,
232            next_fire: std::time::SystemTime::UNIX_EPOCH,
233            team_role: None,
234            wake: true,
235        }
236    }
237
238    /// A payload parked for a sleeping project is taken by the session
239    /// that connects as that slot, and the take is a drain, not a read.
240    #[test]
241    fn a_parked_payload_lands_on_the_slots_connect() {
242        let (ws, _rx) = crate::Workspace::testing_stub();
243        let id = MessageId::mint();
244        ws.park_peer_prompt(&slot(None), &sender(), wrapped(&id, "are you up?"));
245
246        let taken = ws.take_parked_for_slot(&slot(None));
247        assert_eq!(taken.peer.len(), 1, "the slot's own connect takes its bucket");
248        assert_eq!(taken.peer[0].wrapped.id, id, "and it is the payload that was parked");
249        assert!(
250            ws.take_parked_for_slot(&slot(None)).peer.is_empty(),
251            "the take drains: a second connect does not re-deliver",
252        );
253    }
254
255    /// All four parked kinds live in the one bucket, so a cron and a peer
256    /// prompt parked for the same slot come back together.
257    #[test]
258    fn one_bucket_holds_every_kind() {
259        let (ws, _rx) = crate::Workspace::testing_stub();
260        let id = MessageId::mint();
261        ws.park_peer_prompt(&slot(None), &sender(), wrapped(&id, "are you up?"));
262        ws.park_cron(&slot(None), &cron("c1", "morning reminder"), true);
263
264        let taken: ParkedForSlot = ws.take_parked_for_slot(&slot(None));
265        assert_eq!(taken.peer.len(), 1, "the peer prompt is here");
266        assert_eq!(taken.cron.len(), 1, "and so is the cron");
267        assert!(taken.cron[0].missed, "with its missed mark intact");
268        assert_eq!(taken.cron[0].cron_id, "c1", "and the entry it came from");
269        assert_eq!(taken.cron[0].description.as_deref(), Some("c1 summary"));
270    }
271
272    /// The label is half the address. A payload parked for a team worker
273    /// must not land on the project's lead, which is the silent miss this
274    /// bucket shape exists to prevent.
275    #[test]
276    fn a_workers_parked_payload_is_not_the_leads() {
277        let (ws, _rx) = crate::Workspace::testing_stub();
278        let id = MessageId::mint();
279        ws.park_peer_prompt(&slot(Some("planner")), &sender(), wrapped(&id, "planner"));
280
281        assert!(
282            ws.take_parked_for_slot(&slot(None)).peer.is_empty(),
283            "the lead does not drain a worker's bucket",
284        );
285        assert_eq!(
286            ws.take_parked_for_slot(&slot(Some("planner"))).peer.len(),
287            1,
288            "the worker drains its own",
289        );
290    }
291
292    /// The org is part of the key: a payload parked for one org's project
293    /// is not drained by a session under another org that happens to share
294    /// the project name.
295    #[test]
296    fn a_parked_payload_is_not_drained_across_orgs() {
297        let (ws, _rx) = crate::Workspace::testing_stub();
298        let id = MessageId::mint();
299        ws.park_peer_prompt(
300            &SessionSlot::lead("OtherOrg", "parked-proj"),
301            &sender(),
302            wrapped(&id, "elsewhere"),
303        );
304
305        assert!(
306            ws.take_parked_for_slot(&slot(None)).peer.is_empty(),
307            "TestOrg's slot does not take OtherOrg's bucket",
308        );
309    }
310
311    /// A prompt held for a turn end that never comes - the session died
312    /// first - is told to its sender rather than dropped with the bucket.
313    /// The hold is a wait, not a promise: only the turn ending delivers it.
314    #[test]
315    fn expiring_a_slot_acknowledges_its_turn_held_prompts_to_the_sender() {
316        let (ws, mut rx) = crate::Workspace::testing_stub();
317        let target = slot(Some("planner"));
318        let id = MessageId::mint();
319        ws.hold_peer_prompt_unless_turn_ended(
320            &target,
321            &sender(),
322            wrapped(&id, "while you work"),
323            || true,
324        );
325
326        // Nothing else is parked for this slot, so the notice below can only
327        // have come from the held entry.
328        ws.expire_parked_for_slot(&target, PeerFailureReason::TargetConnectionFailed);
329
330        let mut echo = None;
331        while let Ok(frame) = rx.try_recv() {
332            if let SessionUpdate::PeerEnvelopeAppended { key, wrapped, .. } =
333                std::sync::Arc::unwrap_or_clone(frame.update)
334            {
335                echo = Some((key, wrapped));
336            }
337        }
338        let (key, notice) = echo.expect("the notice is painted for the sender");
339        assert_eq!(key, sender(), "the notice lands on the sender, not the target");
340        assert!(
341            matches!(notice.kind, WrappedKind::DeliveryFailureNotice),
342            "the held prompt's sender hears it never landed: {notice:?}",
343        );
344        assert!(
345            ws.take_turn_held_for_slot(&target).is_empty(),
346            "and the hold is gone with the bucket",
347        );
348    }
349
350    /// A spawn that never connects expires what it parked: the sender is
351    /// told its message never landed, and the bucket does not survive to
352    /// leak into a later session.
353    ///
354    /// This is the delivery-ack path, and it is the whole reason the
355    /// parked entry carries a sender - with the ask registry gone, nothing
356    /// else knows who to tell.
357    #[test]
358    fn expiring_a_slot_acknowledges_its_parked_messages_to_the_sender() {
359        let (ws, mut rx) = crate::Workspace::testing_stub();
360        let id = MessageId::mint();
361        ws.park_peer_prompt(&slot(None), &sender(), wrapped(&id, "are you up?"));
362
363        ws.expire_parked_for_slot(&slot(None), PeerFailureReason::TargetConnectionFailed);
364
365        let mut echo = None;
366        while let Ok(frame) = rx.try_recv() {
367            if let SessionUpdate::PeerEnvelopeAppended { key, wrapped, .. } =
368                std::sync::Arc::unwrap_or_clone(frame.update)
369            {
370                echo = Some((key, wrapped));
371            }
372        }
373        let (key, notice) = echo.expect("the notice is painted for the sender");
374        assert_eq!(key, sender(), "the notice lands on the sender, not the target");
375        assert!(
376            matches!(notice.kind, WrappedKind::DeliveryFailureNotice),
377            "and it is the delivery failure, not a peer message",
378        );
379        assert_eq!(
380            notice.sender_name, "parked-proj",
381            "the notice names the seat that never took the message",
382        );
383        assert!(
384            ws.take_parked_for_slot(&slot(None)).peer.is_empty(),
385            "and the bucket is gone, so a later connect cannot deliver it",
386        );
387    }
388}