1use async_trait::async_trait;
2use bamboo_agent_core::tools::ToolExecutor;
3use bamboo_agent_core::{AgentError, AgentEvent, Session};
4use bamboo_domain::AgentRuntimeState;
5use bamboo_llm::LLMProvider;
6use tokio::sync::mpsc;
7use tokio_util::sync::CancellationToken;
8
9use crate::runtime::config::AgentLoopConfig;
10use crate::runtime::task_context::TaskLoopContext;
11
12#[async_trait]
14pub trait LifecycleManager: Send + Sync {
15 fn initialize_run(&self, session: &Session, config: &AgentLoopConfig) -> AgentRuntimeState;
17
18 #[allow(clippy::too_many_arguments)]
22 async fn prepare_round(
23 &self,
24 session: &mut Session,
25 task_context: &mut Option<TaskLoopContext>,
26 runtime_state: &mut AgentRuntimeState,
27 round: usize,
28 max_rounds: Option<usize>,
29 config: &AgentLoopConfig,
30 cancel_token: &CancellationToken,
31 metrics_collector: Option<&bamboo_metrics::MetricsCollector>,
32 session_id: &str,
33 model_name: &str,
34 tools: &dyn ToolExecutor,
35 llm: &dyn LLMProvider,
36 ) -> Result<String, AgentError>;
37
38 async fn prepare_round_with_observation(
40 &self,
41 session: &mut Session,
42 task_context: &mut Option<TaskLoopContext>,
43 runtime_state: &mut AgentRuntimeState,
44 round: usize,
45 max_rounds: Option<usize>,
46 config: &AgentLoopConfig,
47 cancel_token: &CancellationToken,
48 metrics_collector: Option<&bamboo_metrics::MetricsCollector>,
49 session_id: &str,
50 model_name: &str,
51 tools: &dyn ToolExecutor,
52 llm: &dyn LLMProvider,
53 ) -> Result<ObservedRoundPreparation, AgentError> {
54 let round_id = self
55 .prepare_round(
56 session,
57 task_context,
58 runtime_state,
59 round,
60 max_rounds,
61 config,
62 cancel_token,
63 metrics_collector,
64 session_id,
65 model_name,
66 tools,
67 llm,
68 )
69 .await?;
70 Ok(ObservedRoundPreparation {
71 round_id,
72 observation: InputObservation::default(),
73 })
74 }
75
76 async fn handle_round_outcome(
79 &self,
80 session: &mut Session,
81 runtime_state: &mut AgentRuntimeState,
82 task_context: &mut Option<TaskLoopContext>,
83 round: usize,
84 should_break: bool,
85 ) -> Result<bool, AgentError>;
86
87 #[allow(clippy::too_many_arguments)]
89 async fn finalize_run(
90 &self,
91 session: &mut Session,
92 runtime_state: &mut AgentRuntimeState,
93 event_tx: &mpsc::Sender<AgentEvent>,
94 session_id: &str,
95 config: &AgentLoopConfig,
96 metrics_collector: Option<&bamboo_metrics::MetricsCollector>,
97 task_context: Option<TaskLoopContext>,
98 );
99}
100
101pub use crate::runtime::runner::state_bridge::{
102 project_input_request_batch, BorrowedInputRequestRecord,
103};
104
105#[derive(Debug)]
107pub struct ObservedRoundPreparation {
108 pub round_id: String,
109 pub observation: InputObservation,
110}
111
112#[derive(Debug, Default)]
115pub struct InputObservation(ObservationState);
116
117#[derive(Debug, Default)]
118enum ObservationState {
119 #[default]
120 Unavailable,
121 SuccessfulNoNewInput(BoundedInputRequestBatch),
122 New(BoundedInputRequestBatch),
123}
124
125impl InputObservation {
126 pub(crate) fn projected(projection: InputRequestProjection) -> Self {
127 Self(match projection {
128 InputRequestProjection::Available(batch) if batch.records.is_empty() => {
129 ObservationState::SuccessfulNoNewInput(batch)
130 }
131 InputRequestProjection::Available(batch) => ObservationState::New(batch),
132 InputRequestProjection::Unavailable(_) => ObservationState::Unavailable,
133 })
134 }
135
136 pub fn new_inputs(&self) -> Option<&BoundedInputRequestBatch> {
137 match &self.0 {
138 ObservationState::New(batch) => Some(batch),
139 _ => None,
140 }
141 }
142
143 pub fn is_unavailable(&self) -> bool {
144 matches!(self.0, ObservationState::Unavailable)
145 }
146
147 pub fn is_successful_no_new_input(&self) -> bool {
148 matches!(self.0, ObservationState::SuccessfulNoNewInput(_))
149 }
150
151 pub(crate) fn matches_session(&self, session_id: &str) -> bool {
152 match &self.0 {
153 ObservationState::New(batch) | ObservationState::SuccessfulNoNewInput(batch) => {
154 batch.session_id == session_id
155 }
156 ObservationState::Unavailable => false,
157 }
158 }
159
160 pub fn update_current(
163 self,
164 current: &mut Option<BoundedInputRequestBatch>,
165 session_id: &str,
166 execution_id: &str,
167 ) {
168 let matches = |batch: &BoundedInputRequestBatch| {
169 batch.session_id == session_id && batch.execution_id == execution_id
170 };
171 match self.0 {
172 ObservationState::New(batch) if matches(&batch) => *current = Some(batch),
173 ObservationState::SuccessfulNoNewInput(batch)
174 if matches(&batch) && current.as_ref().is_none_or(matches) => {}
175 _ => *current = None,
176 }
177 }
178}
179
180#[derive(Debug, PartialEq)]
182pub struct ProjectedInputRequest {
183 pub input_id: String,
184 pub source: bamboo_domain::SessionMessageSource,
185 pub kind: bamboo_domain::SessionMessageKind,
186 pub wrapper: Option<String>,
187 pub created_at: chrono::DateTime<chrono::Utc>,
188 pub request: Option<bamboo_domain::SessionSkillRequest>,
189}
190
191#[derive(Debug, PartialEq)]
212pub struct BoundedInputRequestBatch {
213 pub(crate) session_id: String,
214 pub(crate) execution_id: String,
215 pub(crate) records: Box<[ProjectedInputRequest]>,
216 pub(crate) compact_bytes: usize,
217}
218
219impl BoundedInputRequestBatch {
220 pub fn session_id(&self) -> &str {
221 &self.session_id
222 }
223 pub fn execution_id(&self) -> &str {
224 &self.execution_id
225 }
226 pub fn records(&self) -> &[ProjectedInputRequest] {
227 &self.records
228 }
229 pub fn compact_bytes(&self) -> usize {
230 self.compact_bytes
231 }
232}
233
234tokio::task_local! {
235 static INPUT_REQUEST_DATA: Option<BoundedInputRequestBatch>;
236}
237
238pub async fn scope_input_request_data<F: std::future::Future>(
267 batch: Option<BoundedInputRequestBatch>,
268 future: F,
269) -> (F::Output, Option<BoundedInputRequestBatch>) {
270 let mut scoped = Box::pin(INPUT_REQUEST_DATA.scope(batch, future));
271 let output = scoped.as_mut().await;
272 let batch = scoped
273 .as_mut()
274 .take_value()
275 .expect("completed input-data scope retains its unique owner");
276 (output, batch)
277}
278
279pub fn with_scoped_input_request_data<R>(
306 session_id: &str,
307 execution_id: &str,
308 reader: impl FnOnce(&BoundedInputRequestBatch) -> R,
309) -> Option<R> {
310 if session_id.is_empty() || execution_id.is_empty() {
311 return None;
312 }
313 INPUT_REQUEST_DATA
314 .try_with(|batch| {
315 batch
316 .as_ref()
317 .filter(|batch| {
318 batch.session_id == session_id && batch.execution_id == execution_id
319 })
320 .map(reader)
321 })
322 .ok()
323 .flatten()
324}
325
326pub const INPUT_REQUEST_FIXED_SLOT_BYTES: usize = 128
328 * std::mem::size_of::<ProjectedInputRequest>()
329 + 4096 * std::mem::size_of::<bamboo_domain::SessionSkillSelection>()
330 + std::mem::size_of::<BoundedInputRequestBatch>();
331
332#[derive(Debug, PartialEq, Eq)]
333pub enum InputRequestUnavailable {
334 RecordLimit { count: usize, limit: usize },
335 InvalidData,
336 CompactLimit { limit: usize },
337}
338
339#[derive(Debug, PartialEq)]
340pub enum InputRequestProjection {
341 Available(BoundedInputRequestBatch),
342 Unavailable(InputRequestUnavailable),
343}
344
345#[cfg(test)]
346mod input_request_batch_tests {
347 fn ql_observation(session: &str, execution: &str, id: Option<&str>) -> InputObservation {
348 let source = bamboo_domain::SessionMessageSource::User;
349 let records = id.map(|id| BorrowedInputRequestRecord {
350 input_id: id,
351 source: &source,
352 kind: bamboo_domain::SessionMessageKind::UserInput,
353 wrapper: None,
354 created_at: chrono::DateTime::from_timestamp(1, 0).unwrap(),
355 request: None,
356 });
357 InputObservation::projected(project_input_request_batch(
358 session,
359 execution,
360 records.as_slice(),
361 ))
362 }
363
364 #[test]
365 fn ql_finite_owner_retains_nonew_only_for_exact_session_and_execution() {
366 let mut current = None;
367 ql_observation("s", "e", Some("N")).update_current(&mut current, "s", "e");
368 let pointer = current.as_ref().unwrap().records().as_ptr();
369 ql_observation("s", "e", None).update_current(&mut current, "s", "e");
370 assert_eq!(current.as_ref().unwrap().records().as_ptr(), pointer);
371 ql_observation("s", "old-execution", None).update_current(&mut current, "s", "e");
372 assert!(
373 current.is_none(),
374 "foreign NoNew cannot preserve this owner's data"
375 );
376 ql_observation("foreign-session", "e", Some("wrong")).update_current(
377 &mut current,
378 "s",
379 "e",
380 );
381 assert!(current.is_none());
382 ql_observation("s", "e", Some("N")).update_current(&mut current, "s", "e");
383 ql_observation("s", "e", None).update_current(&mut current, "successor", "e");
384 assert!(current.is_none());
385 ql_observation("s", "e", Some("N")).update_current(&mut current, "s", "e");
386 InputObservation::default().update_current(&mut current, "s", "e");
387 ql_observation("s", "e", None).update_current(&mut current, "s", "e");
388 assert!(
389 current.is_none(),
390 "NoNew after Unavailable never resurrects N"
391 );
392 }
393
394 use super::*;
395 use crate::runtime::runner::state_bridge::{
396 project_input_request_batch, BorrowedInputRequestRecord,
397 };
398
399 #[test]
400 fn input_request_batch_compiled_fixed_slots_and_moves_preserve_one_owned_value() {
401 let request = bamboo_domain::SessionSkillRequest {
402 mode: None,
403 selections: (0..32)
404 .map(|i| bamboo_domain::SessionSkillSelection {
405 id: format!("{i:x}"),
406 source: "user".into(),
407 revision: 1,
408 args: serde_json::Value::Null,
409 })
410 .collect(),
411 };
412 let records = [BorrowedInputRequestRecord {
413 input_id: "u",
414 source: &bamboo_domain::SessionMessageSource::User,
415 kind: bamboo_domain::SessionMessageKind::UserInput,
416 wrapper: None,
417 created_at: chrono::DateTime::from_timestamp(1, 0).unwrap(),
418 request: Some(&request),
419 }; 128];
420 let make = |execution| match project_input_request_batch("s", execution, &records) {
421 InputRequestProjection::Available(batch) => batch,
422 _ => panic!("full slot fixture must actually fit"),
423 };
424 let mut owner = Some(make("old"));
425 let old_pointer = owner.as_ref().unwrap().records().as_ptr();
426 let prospective = make("new");
427 let new_pointer = prospective.records().as_ptr();
428 assert_ne!(old_pointer, new_pointer);
429 let old = owner.replace(prospective).unwrap();
430 assert_eq!(old.records().as_ptr(), old_pointer);
431 assert_eq!(owner.as_ref().unwrap().records().as_ptr(), new_pointer);
432 drop(old);
433 let batch = owner.take().unwrap();
434 assert!(owner.is_none());
435 assert_eq!(batch.execution_id(), "new");
436 assert_eq!(batch.records().len(), 128);
437 assert_eq!(
438 batch
439 .records
440 .iter()
441 .map(|r| r.request.as_ref().unwrap().selections.capacity())
442 .sum::<usize>(),
443 4096
444 );
445 assert_eq!(
446 INPUT_REQUEST_FIXED_SLOT_BYTES,
447 128 * std::mem::size_of::<ProjectedInputRequest>()
448 + 4096 * std::mem::size_of::<bamboo_domain::SessionSkillSelection>()
449 + std::mem::size_of::<BoundedInputRequestBatch>()
450 );
451 println!(
452 "compiled fixed slots={} Record={} Selection={} Batch={}",
453 INPUT_REQUEST_FIXED_SLOT_BYTES,
454 std::mem::size_of::<ProjectedInputRequest>(),
455 std::mem::size_of::<bamboo_domain::SessionSkillSelection>(),
456 std::mem::size_of::<BoundedInputRequestBatch>()
457 );
458 }
459}
460
461#[cfg(test)]
462mod input_request_scope_tests {
463 use super::*;
464 use bamboo_agent_core::tools::{
465 observed_tool_output_cap, scope_tool_output_cap, FunctionCall, ToolCall, ToolCtx,
466 ToolError, ToolResult, ToolSchema,
467 };
468 use futures::{future::join_all, FutureExt};
469 use std::sync::{
470 atomic::{AtomicUsize, Ordering},
471 Arc,
472 };
473
474 fn batch(session: &str, execution: &str, id: &str) -> BoundedInputRequestBatch {
475 let request = bamboo_domain::SessionSkillRequest {
476 mode: Some("plan".into()),
477 selections: vec![bamboo_domain::SessionSkillSelection {
478 id: "untrusted-selection".into(),
479 source: "user".into(),
480 revision: 7,
481 args: serde_json::json!({"text": "bounded arguments"}),
482 }],
483 };
484 let records = [BorrowedInputRequestRecord {
485 input_id: id,
486 source: &bamboo_domain::SessionMessageSource::User,
487 kind: bamboo_domain::SessionMessageKind::UserInput,
488 wrapper: None,
489 created_at: chrono::DateTime::from_timestamp(1, 0).unwrap(),
490 request: Some(&request),
491 }];
492 let InputRequestProjection::Available(batch) =
493 project_input_request_batch(session, execution, &records)
494 else {
495 panic!("bounded fixture must fit")
496 };
497 batch
498 }
499
500 fn pointer(session: &str, execution: &str) -> Option<usize> {
501 with_scoped_input_request_data(session, execution, |batch| {
502 batch.records().as_ptr() as usize
503 })
504 }
505
506 #[tokio::test]
507 async fn owned_scope_pending_and_ready_return_the_same_batch_and_args_allocations() {
508 let original = batch("s", "e", "N");
509 let records = original.records().as_ptr() as usize;
510 let args = original.records()[0].request.as_ref().unwrap().selections[0].args["text"]
511 .as_str()
512 .unwrap()
513 .as_ptr() as usize;
514 let bytes = original.compact_bytes();
515 let mut future = Box::pin(scope_input_request_data(Some(original), async {
516 assert_eq!(pointer("s", "e"), Some(records));
517 tokio::task::yield_now().await;
518 with_scoped_input_request_data("s", "e", |batch| {
519 assert_eq!(batch.compact_bytes(), bytes);
520 assert_eq!(batch.records()[0].input_id, "N");
521 let request = batch.records()[0].request.as_ref().unwrap();
522 assert_eq!(request.mode.as_deref(), Some("plan"));
523 assert_eq!(
524 request.selections[0].args["text"]
525 .as_str()
526 .unwrap()
527 .as_ptr() as usize,
528 args
529 );
530 43
531 })
532 }));
533 assert!(futures::poll!(future.as_mut()).is_pending());
534 assert_eq!(pointer("s", "e"), None, "restored between polls");
535 let (result, returned) = future.await;
536 assert_eq!(result, Some(43));
537 assert_eq!(returned.unwrap().records().as_ptr() as usize, records);
538 assert_eq!(pointer("s", "e"), None);
539 }
540
541 #[tokio::test]
542 async fn owned_scope_absent_and_mismatched_ids_never_call_the_reader() {
543 let calls = AtomicUsize::new(0);
544 assert_eq!(
545 with_scoped_input_request_data("s", "e", |_| calls.fetch_add(1, Ordering::SeqCst)),
546 None
547 );
548 scope_input_request_data(Some(batch("s", "e", "N")), async {
549 for (session, execution) in [
550 ("", "e"),
551 ("s", ""),
552 ("other", "e"),
553 ("s", "other"),
554 (" s", "e"),
555 ] {
556 assert_eq!(
557 with_scoped_input_request_data(session, execution, |_| calls
558 .fetch_add(1, Ordering::SeqCst)),
559 None
560 );
561 }
562 assert!(pointer("s", "e").is_some());
563 })
564 .await;
565 scope_input_request_data(Some(batch("", "", "N")), async {
566 assert_eq!(pointer("", ""), None);
567 })
568 .await;
569 scope_input_request_data(None, async {
570 assert_eq!(pointer("s", "e"), None);
571 })
572 .await;
573 assert_eq!(calls.load(Ordering::SeqCst), 0);
574 }
575
576 #[tokio::test]
577 async fn owned_scope_nested_values_and_explicit_absence_restore_the_outer_value() {
578 let original = batch("s", "e", "outer");
579 let outer = original.records().as_ptr() as usize;
580 let (_, returned) = scope_input_request_data(Some(original), async {
581 let inner = batch("s", "inner", "inner");
582 let inner_pointer = inner.records().as_ptr() as usize;
583 let (_, inner) = scope_input_request_data(Some(inner), async {
584 assert_eq!(pointer("s", "e"), None);
585 assert_eq!(pointer("s", "inner"), Some(inner_pointer));
586 tokio::task::yield_now().await;
587 assert_eq!(pointer("s", "inner"), Some(inner_pointer));
588 })
589 .await;
590 assert_eq!(inner.unwrap().records().as_ptr() as usize, inner_pointer);
591 assert_eq!(pointer("s", "e"), Some(outer));
592 let (_, absent) = scope_input_request_data(None, async {
593 assert_eq!(pointer("s", "e"), None);
594 tokio::task::yield_now().await;
595 assert_eq!(pointer("s", "e"), None);
596 })
597 .await;
598 assert!(absent.is_none());
599 assert_eq!(pointer("s", "e"), Some(outer));
600 })
601 .await;
602 assert_eq!(returned.unwrap().records().as_ptr() as usize, outer);
603 }
604
605 struct DataExecutor {
606 entered: tokio::sync::Barrier,
607 }
608
609 #[async_trait]
610 impl ToolExecutor for DataExecutor {
611 async fn execute(&self, call: &ToolCall) -> Result<ToolResult, ToolError> {
612 let mut ctx = ToolCtx::none(call.id.clone());
613 ctx.session_id = Some("s".into());
614 let before = pointer("s", "e").expect("real executor future sees the owned batch");
615 let cap = observed_tool_output_cap(&ctx).unwrap();
616 self.entered.wait().await;
617 tokio::task::yield_now().await;
618 assert_eq!(pointer("s", "e"), Some(before));
619 assert_eq!(observed_tool_output_cap(&ctx), Some(cap));
620 Ok(ToolResult {
621 success: true,
622 result: serde_json::json!({"pointer": before, "cap": cap}).to_string(),
623 ..Default::default()
624 })
625 }
626
627 fn list_tools(&self) -> Vec<ToolSchema> {
628 Vec::new()
629 }
630 }
631
632 #[tokio::test]
633 async fn owned_scope_real_inline_executor_futures_share_data_with_independent_dispatch_caps() {
634 let executor = DataExecutor {
635 entered: tokio::sync::Barrier::new(2),
636 };
637 let original = batch("s", "e", "N");
638 let records = original.records().as_ptr() as usize;
639 let calls = [7u32, 19].map(|cap| ToolCall {
640 id: cap.to_string(),
641 tool_type: "function".into(),
642 function: FunctionCall {
643 name: "data-probe".into(),
644 arguments: "{}".into(),
645 },
646 });
647 let (results, returned) = scope_input_request_data(Some(original), async {
648 join_all(calls.iter().zip([7, 19]).map(|(call, cap)| {
649 scope_tool_output_cap("s", &call.id, Some(cap), executor.execute(call))
650 }))
651 .await
652 })
653 .await;
654 for (result, cap) in results.into_iter().zip([7, 19]) {
655 let value: serde_json::Value = serde_json::from_str(&result.unwrap().result).unwrap();
656 assert_eq!(value["pointer"], records);
657 assert_eq!(value["cap"], cap);
658 }
659 assert_eq!(returned.unwrap().records().as_ptr() as usize, records);
660 assert_eq!(pointer("s", "e"), None);
661 }
662
663 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
664 async fn owned_scope_whole_send_futures_with_identical_ids_stay_task_local() {
665 let barrier = Arc::new(tokio::sync::Barrier::new(2));
666 let mut tasks = Vec::new();
667 for id in ["A", "B"] {
668 let barrier = barrier.clone();
669 let original = batch("s", "e", id);
670 let records = original.records().as_ptr() as usize;
671 tasks.push(tokio::spawn(scope_input_request_data(
672 Some(original),
673 async move {
674 barrier.wait().await;
675 for _ in 0..10 {
676 tokio::task::yield_now().await;
677 assert_eq!(pointer("s", "e"), Some(records));
678 assert_eq!(
679 with_scoped_input_request_data("s", "e", |batch| batch.records()[0]
680 .input_id
681 .clone())
682 .as_deref(),
683 Some(id)
684 );
685 }
686 records
687 },
688 )));
689 }
690 for task in tasks {
691 let (records, returned) = task.await.unwrap();
692 assert_eq!(returned.unwrap().records().as_ptr() as usize, records);
693 }
694 assert_eq!(pointer("s", "e"), None);
695 }
696
697 #[tokio::test]
698 async fn owned_scope_unwrapped_spawn_and_spawn_blocking_do_not_inherit_data() {
699 scope_input_request_data(Some(batch("s", "e", "N")), async {
700 let records = pointer("s", "e");
701 assert!(records.is_some());
702 assert_eq!(
703 tokio::spawn(async { pointer("s", "e") }).await.unwrap(),
704 None
705 );
706 assert_eq!(
707 tokio::task::spawn_blocking(|| pointer("s", "e"))
708 .await
709 .unwrap(),
710 None
711 );
712 assert_eq!(pointer("s", "e"), records);
713 })
714 .await;
715 }
716
717 #[tokio::test]
718 async fn owned_scope_inline_non_send_future_is_supported() {
719 let local = std::rc::Rc::new(41);
720 let (result, returned) = scope_input_request_data(Some(batch("s", "e", "N")), async {
721 let local = local.clone();
722 tokio::task::yield_now().await;
723 assert!(pointer("s", "e").is_some());
724 *local
725 })
726 .await;
727 assert_eq!(result, 41);
728 assert!(returned.is_some());
729 assert_eq!(pointer("s", "e"), None);
730 }
731
732 struct DropProbe(Arc<AtomicUsize>);
733 impl Drop for DropProbe {
734 fn drop(&mut self) {
735 self.0.fetch_add(1, Ordering::SeqCst);
736 }
737 }
738
739 #[tokio::test]
740 async fn owned_scope_cancellation_drops_work_once_and_restores_outer_data() {
741 let drops = Arc::new(AtomicUsize::new(0));
742 let probe = DropProbe(drops.clone());
743 let (_, returned) = scope_input_request_data(Some(batch("s", "outer", "outer")), async {
744 let outer = pointer("s", "outer");
745 let mut cancelled = Box::pin(scope_input_request_data(
746 Some(batch("s", "e", "cancelled")),
747 async move {
748 let _probe = probe;
749 assert!(pointer("s", "e").is_some());
750 assert_eq!(pointer("s", "outer"), None);
751 std::future::pending::<()>().await;
752 },
753 ));
754 assert!(futures::poll!(cancelled.as_mut()).is_pending());
755 assert_eq!(pointer("s", "e"), None);
756 assert_eq!(pointer("s", "outer"), outer);
757 drop(cancelled);
758 assert_eq!(drops.load(Ordering::SeqCst), 1);
759 assert_eq!(pointer("s", "e"), None);
760 assert_eq!(pointer("s", "outer"), outer);
761 })
762 .await;
763 assert!(returned.is_some());
764 assert_eq!(drops.load(Ordering::SeqCst), 1);
765 assert_eq!(pointer("s", "outer"), None);
766 }
767
768 #[tokio::test]
769 async fn owned_scope_unwind_drops_work_once_and_restores_outer_data() {
770 let drops = Arc::new(AtomicUsize::new(0));
771 let probe = DropProbe(drops.clone());
772 scope_input_request_data(Some(batch("s", "outer", "outer")), async {
773 let outer = pointer("s", "outer");
774 let result = std::panic::AssertUnwindSafe(scope_input_request_data(
775 Some(batch("s", "e", "panicked")),
776 async move {
777 let _probe = probe;
778 tokio::task::yield_now().await;
779 assert!(pointer("s", "e").is_some());
780 panic!("intentional scoped-work panic");
781 },
782 ))
783 .catch_unwind()
784 .await;
785 assert!(result.is_err());
786 assert_eq!(drops.load(Ordering::SeqCst), 1);
787 assert_eq!(pointer("s", "e"), None);
788 assert_eq!(pointer("s", "outer"), outer);
789 })
790 .await;
791 assert_eq!(pointer("s", "outer"), None);
792 }
793
794 #[tokio::test]
795 async fn owned_scope_error_result_returns_ownership_and_never_retains_a_scope() {
796 let original = batch("s", "e", "N");
797 let records = original.records().as_ptr() as usize;
798 let (result, returned) = scope_input_request_data(Some(original), async {
799 tokio::task::yield_now().await;
800 Err::<(), _>("real error result")
801 })
802 .await;
803 assert_eq!(result, Err("real error result"));
804 assert_eq!(returned.unwrap().records().as_ptr() as usize, records);
805 assert_eq!(pointer("s", "e"), None);
806 }
807
808 #[tokio::test]
809 async fn owned_scope_round_updates_retain_nonew_replace_new_and_clear_unavailable() {
810 let mut current = None;
811 InputObservation::projected(InputRequestProjection::Available(batch("s", "e", "N")))
812 .update_current(&mut current, "s", "e");
813 let original = current.as_ref().unwrap().records().as_ptr() as usize;
814 let (_, returned) = scope_input_request_data(current.take(), async {
815 assert_eq!(pointer("s", "e"), Some(original));
816 })
817 .await;
818 current = returned;
819 InputObservation::projected(project_input_request_batch("s", "e", &[])).update_current(
820 &mut current,
821 "s",
822 "e",
823 );
824 assert_eq!(
825 current.as_ref().unwrap().records().as_ptr() as usize,
826 original
827 );
828 let mut newer = batch("s", "e", "N2");
829 newer.records[0].request = None;
830 InputObservation::projected(InputRequestProjection::Available(newer)).update_current(
831 &mut current,
832 "s",
833 "e",
834 );
835 let (_, returned) = scope_input_request_data(current.take(), async {
836 with_scoped_input_request_data("s", "e", |batch| {
837 assert_eq!(batch.records()[0].input_id, "N2");
838 assert!(batch.records()[0].request.is_none());
839 })
840 .unwrap();
841 })
842 .await;
843 current = returned;
844 InputObservation::default().update_current(&mut current, "s", "e");
845 InputObservation::projected(project_input_request_batch("s", "e", &[])).update_current(
846 &mut current,
847 "s",
848 "e",
849 );
850 let (_, returned) = scope_input_request_data(current, async {
851 assert_eq!(
852 pointer("s", "e"),
853 None,
854 "NoNew cannot resurrect cleared data"
855 );
856 })
857 .await;
858 assert!(returned.is_none());
859 }
860
861 #[tokio::test]
862 async fn owned_scope_aborting_a_real_task_drops_work_without_inheriting_data() {
863 let drops = Arc::new(AtomicUsize::new(0));
864 let probe = DropProbe(drops.clone());
865 let entered = Arc::new(tokio::sync::Notify::new());
866 let task_entered = entered.clone();
867 let task = tokio::spawn(scope_input_request_data(
868 Some(batch("s", "e", "aborted")),
869 async move {
870 let _probe = probe;
871 assert!(pointer("s", "e").is_some());
872 task_entered.notify_one();
873 std::future::pending::<()>().await;
874 },
875 ));
876 entered.notified().await;
877 assert_eq!(pointer("s", "e"), None);
878 task.abort();
879 assert!(task.await.unwrap_err().is_cancelled());
880 assert_eq!(drops.load(Ordering::SeqCst), 1);
881 assert_eq!(pointer("s", "e"), None);
882 }
883
884 #[tokio::test]
885 async fn owned_scope_maximum_record_batch_remains_one_allocation() {
886 let ids = (0..128).map(|i| format!("input-{i}")).collect::<Vec<_>>();
887 let source = bamboo_domain::SessionMessageSource::User;
888 let records = ids
889 .iter()
890 .map(|id| BorrowedInputRequestRecord {
891 input_id: id,
892 source: &source,
893 kind: bamboo_domain::SessionMessageKind::UserInput,
894 wrapper: None,
895 created_at: chrono::DateTime::from_timestamp(1, 0).unwrap(),
896 request: None,
897 })
898 .collect::<Vec<_>>();
899 let InputRequestProjection::Available(original) =
900 project_input_request_batch("s", "e", &records)
901 else {
902 panic!("maximum slots must fit")
903 };
904 let address = original.records().as_ptr() as usize;
905 let (_, returned) = scope_input_request_data(Some(original), async {
906 tokio::task::yield_now().await;
907 with_scoped_input_request_data("s", "e", |batch| {
908 assert_eq!(batch.records().len(), 128);
909 assert_eq!(batch.records().as_ptr() as usize, address);
910 assert_eq!(batch.records()[127].input_id, "input-127");
911 })
912 .unwrap();
913 })
914 .await;
915 assert_eq!(returned.unwrap().records().as_ptr() as usize, address);
916 }
917}