1use 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#[derive(Default)]
15pub(crate) struct ParkedForSlot {
16 pub peer: Vec<ParkedPeer>,
17 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
25pub(crate) struct ParkedPeer {
33 pub(crate) sender: SessionSlot,
34 pub(crate) wrapped: WrappedPrompt,
35}
36
37pub(crate) type ParkedMap = HashMap<SessionSlot, ParkedForSlot>;
39
40impl crate::Workspace {
41 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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 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 #[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}