1use crate::agent::consumer::{
2 AgentMessage, MAX_CONSECUTIVE_POLL_ERRORS, backoff_for, decode_agent_record,
3};
4use crate::agent::control::{
5 ControlBook, ControlPosition, ControlState, PlacedRequest, control_request,
6};
7use crate::agent::{Session, Sessions};
8use crate::context::READ_BATCH;
9use crate::error::LaserError;
10use crate::laser::Laser;
11use crate::poll::{DrainRange, bounded_partitions, drain_partition, tail_anchored_offset};
12use crate::provenance::AgentTopic;
13use crate::stream::ConsumerMessage;
14use crate::types::ConversationId;
15use futures::future::BoxFuture;
16use iggy::prelude::{
17 Identifier, IggyClient, IggyMessage, MessageClient, PollingStrategy, StreamClient, TopicClient,
18};
19use laser_wire::agent::{
20 AgentId, AgentKind, LogPosition, OPERATION_CHAT, OPERATION_SESSION, OPERATION_SESSION_PARKED,
21 OPERATION_SESSION_UNPARKED, SessionParking, SessionTransition, TaskState, features,
22};
23use laser_wire::content::ContentType;
24use laser_wire::dispatch::{
25 CONTROL_OPERATIONS, OPERATION_EXECUTE_TOOL, OPERATION_GENERATE_CONTENT,
26 OPERATION_TEXT_COMPLETION,
27};
28use laser_wire::framing::{decode_named, encode_named};
29use laser_wire::graph::SourceRef;
30use laser_wire::topics::{AGENT_CONTROL, AGENT_SESSIONS};
31use std::collections::{BTreeMap, BTreeSet, HashMap};
32use std::sync::atomic::{AtomicU64, Ordering};
33use std::sync::{Arc, Mutex};
34use std::time::Duration;
35use tokio::sync::{mpsc, oneshot};
36use tracing::{debug, warn};
37
38#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ParkedRecords {
42 pub records: Vec<SessionParking>,
44 pub complete: bool,
49}
50
51impl Session {
52 pub async fn parked(&self) -> Result<ParkedRecords, LaserError> {
58 let session = self.conversation();
59 let mut scan = scan_lane(&self.laser, None, Some(session)).await?;
60 let mut complete = scan.complete_for(session);
61 let pending = scan
62 .sessions
63 .remove(&session)
64 .map(|facts| facts.pending())
65 .unwrap_or_default();
66 let mut retained = SourceCheck::new(&self.laser);
67 for key in pending.keys() {
68 if !retained.readable(key.source).await? {
69 complete = false;
70 }
71 }
72 Ok(ParkedRecords {
73 records: pending.into_values().collect(),
74 complete,
75 })
76 }
77}
78
79struct SourceCheck<'a> {
82 laser: &'a Laser,
83 generations: HashMap<(u32, u32), Option<u64>>,
84 first: HashMap<(u32, u32, u32), Option<u64>>,
85}
86
87impl<'a> SourceCheck<'a> {
88 fn new(laser: &'a Laser) -> Self {
89 Self {
90 laser,
91 generations: HashMap::new(),
92 first: HashMap::new(),
93 }
94 }
95
96 async fn readable(&mut self, address: SourceAddress) -> Result<bool, LaserError> {
97 let stream = Identifier::numeric(address.stream)?;
98 let topic = Identifier::numeric(address.topic)?;
99 let client = self.laser.client();
100 let generation = match self.generations.get(&(address.stream, address.topic)) {
101 Some(generation) => *generation,
102 None => {
103 let generation = client
104 .get_topic(&stream, &topic)
105 .await?
106 .map(|details| details.created_at.as_micros());
107 self.generations
108 .insert((address.stream, address.topic), generation);
109 generation
110 }
111 };
112 if generation.is_none() || generation != address.generation {
113 return Ok(false);
114 }
115 let partition = (address.stream, address.topic, address.partition);
116 let first = match self.first.get(&partition) {
117 Some(first) => *first,
118 None => {
119 let first = client
120 .poll_messages(
121 &stream,
122 &topic,
123 Some(address.partition),
124 &iggy::prelude::Consumer::new(Identifier::named(PARKED_READER)?),
125 &PollingStrategy::first(),
126 1,
127 false,
128 )
129 .await?
130 .messages
131 .first()
132 .map(|message| message.header.offset);
133 self.first.insert(partition, first);
134 first
135 }
136 };
137 Ok(first.is_some_and(|first| first <= address.offset))
138 }
139}
140
141pub(crate) async fn lane_participants(
145 laser: &Laser,
146 session: ConversationId,
147) -> Result<Vec<AgentId>, LaserError> {
148 let records = laser
149 .context(session)
150 .fetch_with(
151 vec![AgentTopic::Sessions],
152 Box::new(crate::context::LastN(usize::MAX)),
153 )
154 .await?;
155 let mut participants = BTreeSet::new();
156 for envelope in records.into_iter().filter_map(|record| record.envelope) {
157 let operation = envelope.operation.as_deref().unwrap_or_default();
158 match envelope.kind {
159 AgentKind::Command if is_work(operation) => {
160 participants.extend(envelope.target);
161 }
162 AgentKind::Status if operation == OPERATION_SESSION => {
163 if let Ok(SessionTransition {
164 actor: Some(actor), ..
165 }) = decode_named::<SessionTransition>(&envelope.body)
166 {
167 participants.insert(actor);
168 }
169 }
170 _ => {}
171 }
172 }
173 Ok(participants.into_iter().collect())
174}
175
176fn is_work(operation: &str) -> bool {
177 !CONTROL_OPERATIONS.contains(&operation)
178 && !matches!(
179 operation,
180 OPERATION_CHAT
181 | OPERATION_TEXT_COMPLETION
182 | OPERATION_GENERATE_CONTENT
183 | OPERATION_EXECUTE_TOOL
184 )
185}
186
187pub(crate) trait Replay: Send + Sync {
191 fn replay<'a>(&'a self, received: &'a ConsumerMessage)
192 -> BoxFuture<'a, Result<(), LaserError>>;
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub(crate) enum Hold {
198 Handle,
200 Commit,
203}
204
205#[derive(Debug, Clone, Copy)]
207pub(crate) struct SourceTopic {
208 pub(crate) stream_id: u32,
209 pub(crate) topic_id: u32,
210 pub(crate) generation: u64,
212}
213
214pub(crate) struct ControlFeed {
217 pub(crate) stream_id: u32,
218 pub(crate) topic_id: u32,
219 topic_gen: u64,
221 next: BTreeMap<u32, u64>,
222 truncated: bool,
225}
226
227pub(crate) async fn load_control(
231 laser: &Laser,
232 me: Option<&AgentId>,
233 book: &ControlBook,
234) -> Result<Option<ControlFeed>, LaserError> {
235 let stream = Identifier::named(laser.stream_required()?)?;
236 let topic = Identifier::named(AGENT_CONTROL)?;
237 let client = laser.client();
238 let Some(details) = client.get_topic(&stream, &topic).await? else {
239 return Ok(None);
240 };
241 let stream_id = client
242 .get_stream(&stream)
243 .await?
244 .map(|stream| stream.id)
245 .unwrap_or_default();
246 let reader = iggy::prelude::Consumer::new(Identifier::named(CONTROL_READER)?);
247 let mut next = BTreeMap::new();
248 let mut truncated = false;
249 let mut records = Vec::new();
250 for partition in 0..bounded_partitions(details.partitions_count) {
251 let window = read_window(&client, &stream, &topic, &reader, partition).await?;
252 truncated |= window.truncated;
253 next.insert(partition, window.next_offset);
254 for message in window.messages {
255 let Ok(decoded) = decode_agent_record(&message, features::NONE) else {
256 continue;
257 };
258 let Some(envelope) = decoded.envelope else {
259 continue;
260 };
261 if let Some(request) = control_request(&envelope, me) {
262 records.push((
263 envelope.conversation.into(),
264 (partition, message.header.offset),
265 request,
266 ));
267 }
268 }
269 }
270 book.set_partitions(details.partitions_count);
271 book.load(records);
272 Ok(Some(ControlFeed {
273 stream_id,
274 topic_id: details.id,
275 topic_gen: details.created_at.as_micros(),
276 next,
277 truncated,
278 }))
279}
280
281use crate::retry::CONTROL_DISCOVERY_MAX;
290
291pub(crate) type StartPause = Box<
297 dyn FnOnce(
298 &ControlFeed,
299 )
300 -> futures::future::BoxFuture<'static, Option<mpsc::UnboundedSender<ConversationId>>>
301 + Send,
302>;
303
304pub(crate) async fn discover_then_follow_control(
305 laser: Laser,
306 me: Option<AgentId>,
307 book: Arc<ControlBook>,
308 start_pause: Option<StartPause>,
309 poll_interval: Duration,
310 mut stop: oneshot::Receiver<()>,
311) {
312 let mut delay = poll_interval.max(Duration::from_millis(100));
313 let feed = loop {
314 tokio::select! {
315 _ = &mut stop => return,
316 () = tokio::time::sleep(crate::retry::jittered(delay)) => {}
317 }
318 match load_control(&laser, me.as_ref(), &book).await {
319 Ok(Some(feed)) => break feed,
320 Ok(None) => {}
321 Err(error) => {
322 tracing::debug!(%error, "looking for the agent control topic failed, trying again");
323 }
324 }
325 delay = delay.saturating_mul(2).min(CONTROL_DISCOVERY_MAX);
326 };
327 book.set_live(true);
328 tracing::info!("the agent control topic appeared, following operator requests");
329 let notify = match start_pause {
330 Some(start) => start(&feed).await,
331 None => None,
332 };
333 follow_control(laser, feed, me, book, notify, poll_interval, stop).await;
334}
335
336pub(crate) async fn follow_control(
337 laser: Laser,
338 mut feed: ControlFeed,
339 me: Option<AgentId>,
340 book: Arc<ControlBook>,
341 notify: Option<mpsc::UnboundedSender<ConversationId>>,
342 poll_interval: Duration,
343 mut stop: oneshot::Receiver<()>,
344) {
345 let (stream, topic, reader) = match (
346 laser
347 .stream_required()
348 .and_then(|stream| Ok(Identifier::named(stream)?)),
349 Identifier::named(AGENT_CONTROL),
350 Identifier::named(CONTROL_READER),
351 ) {
352 (Ok(stream), Ok(topic), Ok(reader)) => {
353 (stream, topic, iggy::prelude::Consumer::new(reader))
354 }
355 _ => {
356 book.set_live(false);
357 return;
358 }
359 };
360 let mut failures = 0u32;
361 let mut checked = tokio::time::Instant::now();
362 loop {
363 let pass = async {
364 let client = laser.client();
365 let mut read = false;
366 for (partition, next) in &mut feed.next {
367 let batch = drain_partition(
368 &client,
369 &stream,
370 &topic,
371 &reader,
372 DrainRange::open(*partition, *next),
373 READ_BATCH,
374 )
375 .await?;
376 read |= !batch.messages.is_empty();
377 for message in batch.messages {
378 observe_control(&message, *partition, me.as_ref(), &book, notify.as_ref());
379 }
380 *next = batch.next_offset;
381 }
382 Ok::<bool, LaserError>(read)
383 };
384 let outcome = tokio::select! {
385 _ = &mut stop => break,
386 outcome = pass => outcome,
387 };
388 let idle = match outcome {
389 Ok(read) => {
390 failures = 0;
391 !read
392 }
393 Err(error) => {
394 failures += 1;
395 if failures >= MAX_CONSECUTIVE_POLL_ERRORS {
396 warn!(%error, "the control follower stopped, session control is read from the log");
397 break;
398 }
399 debug!(%error, attempt = failures, "reading agent.control failed, retrying");
400 tokio::select! {
401 _ = &mut stop => break,
402 () = tokio::time::sleep(backoff_for(failures)) => {}
403 }
404 continue;
405 }
406 };
407 if idle {
408 if checked.elapsed() >= CONTROL_TOPIC_CHECK {
411 checked = tokio::time::Instant::now();
412 if let Ok(Some(details)) =
413 iggy::prelude::TopicClient::get_topic(laser.client().as_ref(), &stream, &topic)
414 .await
415 && (details.id != feed.topic_id
416 || details.created_at.as_micros() != feed.topic_gen)
417 {
418 book.reset();
419 match load_control(&laser, me.as_ref(), &book).await {
420 Ok(Some(reloaded)) => {
421 debug!("agent.control was recreated, its sessions are read again");
422 feed = reloaded;
423 continue;
424 }
425 _ => break,
426 }
427 }
428 }
429 tokio::select! {
430 _ = &mut stop => break,
431 () = tokio::time::sleep(poll_interval) => {}
432 }
433 }
434 }
435 book.set_live(false);
436}
437
438const CONTROL_TOPIC_CHECK: Duration = Duration::from_secs(5);
441
442fn observe_control(
443 message: &IggyMessage,
444 partition: u32,
445 me: Option<&AgentId>,
446 book: &ControlBook,
447 notify: Option<&mpsc::UnboundedSender<ConversationId>>,
448) {
449 let Ok(decoded) = decode_agent_record(message, features::NONE) else {
450 return;
451 };
452 let Some(envelope) = decoded.envelope else {
453 return;
454 };
455 let Some(request) = control_request(&envelope, me) else {
456 return;
457 };
458 let session: ConversationId = envelope.conversation.into();
459 book.observe(session, (partition, message.header.offset), request);
460 if let Some(notify) = notify {
461 let _ = notify.send(session);
462 }
463}
464
465pub(crate) struct PauseRuntime {
472 laser: Laser,
473 sessions: Sessions,
474 me: AgentId,
475 book: Arc<ControlBook>,
476 control: (u32, u32),
477 control_truncated: bool,
478 source: SourceTopic,
479 reconcile: mpsc::UnboundedSender<ConversationId>,
480 gates: Mutex<HashMap<ConversationId, Arc<tokio::sync::Mutex<()>>>>,
481 roles: Mutex<HashMap<ConversationId, RoleState>>,
482 assignment: Mutex<Option<BTreeSet<u32>>>,
483 epoch: AtomicU64,
484 recovered_epoch: AtomicU64,
485 recovering: tokio::sync::Mutex<()>,
486}
487
488impl PauseRuntime {
489 pub(crate) fn new(
490 sessions: Sessions,
491 me: AgentId,
492 book: Arc<ControlBook>,
493 feed: &ControlFeed,
494 source: SourceTopic,
495 reconcile: mpsc::UnboundedSender<ConversationId>,
496 ) -> Self {
497 Self {
498 laser: sessions.laser().clone(),
499 sessions,
500 me,
501 book,
502 control: (feed.stream_id, feed.topic_id),
503 control_truncated: feed.truncated,
504 source,
505 reconcile,
506 gates: Mutex::default(),
507 roles: Mutex::default(),
508 assignment: Mutex::default(),
509 epoch: AtomicU64::new(1),
510 recovered_epoch: AtomicU64::new(0),
511 recovering: tokio::sync::Mutex::new(()),
512 }
513 }
514
515 pub(crate) async fn gate(&self, session: ConversationId) -> Gate<'_> {
519 let mutex = Arc::clone(lock(&self.gates).entry(session).or_default());
520 let guard = mutex.lock_owned().await;
521 Gate {
522 runtime: self,
523 session,
524 guard: Some(guard),
525 }
526 }
527
528 pub(crate) async fn hold(
534 &self,
535 worker: &dyn Replay,
536 message: &AgentMessage,
537 ) -> Result<Hold, LaserError> {
538 let session = message.provenance.conversation_id;
539 self.ensure_recovered().await?;
540 loop {
541 let control = self.control_state(session).await?;
542 if let Some(pause) = control.paused() {
543 self.acknowledge(session, TaskState::Paused, pause).await?;
544 if control.flags.cancel_requested {
545 self.cancel_paused(session, true).await;
546 }
547 self.park(session, message, pause).await?;
548 return Ok(Hold::Commit);
549 }
550 self.acknowledge_resume(session, &control).await?;
551 if !control.flags.cancel_requested
552 && self.has_pending(session)
553 && self.drain(worker, session, false).await? == Drained::Interrupted
554 {
555 continue;
556 }
557 return Ok(if self.handled(session, self.address(message)) {
560 Hold::Commit
561 } else {
562 Hold::Handle
563 });
564 }
565 }
566
567 pub(crate) async fn reconcile(&self, worker: &dyn Replay, session: ConversationId) {
572 let _gate = self.gate(session).await;
573 if let Err(error) = self.reconcile_locked(worker, session).await {
574 warn!(%error, %session, "bringing a session up to date with its control requests failed, the next record or request retries");
575 }
576 }
577
578 pub(crate) async fn recover(&self) -> Result<(), LaserError> {
580 self.ensure_recovered().await
581 }
582
583 pub(crate) fn observe_assignment(&self, assigned: Option<BTreeSet<u32>>) {
587 let Some(assigned) = assigned else {
588 return;
589 };
590 let mut current = lock(&self.assignment);
591 match current.as_ref() {
592 Some(known) if *known == assigned => {}
593 Some(_) => {
594 *current = Some(assigned);
595 drop(current);
596 self.invalidate();
597 }
598 None => *current = Some(assigned),
599 }
600 }
601
602 pub(crate) fn invalidate(&self) {
605 self.epoch.fetch_add(1, Ordering::AcqRel);
606 }
607
608 async fn reconcile_locked(
609 &self,
610 worker: &dyn Replay,
611 session: ConversationId,
612 ) -> Result<(), LaserError> {
613 self.ensure_recovered().await?;
614 let control = self.control_state(session).await?;
615 if let Some(pause) = control.paused() {
616 if control.flags.cancel_requested {
617 self.cancel_paused(session, pause.named).await;
618 } else if pause.named || self.involved(session) {
619 self.acknowledge(session, TaskState::Paused, pause).await?;
620 }
621 return Ok(());
622 }
623 self.acknowledge_resume(session, &control).await?;
624 if !control.flags.cancel_requested && self.has_pending(session) {
625 self.drain(worker, session, true).await?;
626 }
627 Ok(())
628 }
629
630 async fn ensure_recovered(&self) -> Result<(), LaserError> {
634 if self.recovered_epoch.load(Ordering::Acquire) >= self.epoch.load(Ordering::Acquire) {
635 return Ok(());
636 }
637 let _recovering = self.recovering.lock().await;
638 let epoch = self.epoch.load(Ordering::Acquire);
639 if self.recovered_epoch.load(Ordering::Acquire) >= epoch {
640 return Ok(());
641 }
642 if self.control_truncated || self.book.has_pause_history() {
643 let scan = scan_lane(&self.laser, Some(&self.me), None).await?;
644 if !scan.truncated.is_empty() {
645 warn!(
646 partitions = ?scan.truncated,
647 "session recovery read only the newest records of these agent.sessions partitions, held work older than that read is not recovered"
648 );
649 }
650 let mut touched: std::collections::HashSet<ConversationId> =
651 scan.sessions.keys().copied().collect();
652 self.merge(scan);
653 touched.extend(self.book.paused_or_resumed());
654 for session in touched {
655 let _ = self.reconcile.send(session);
656 }
657 }
658 self.recovered_epoch.store(epoch, Ordering::Release);
659 Ok(())
660 }
661
662 fn merge(&self, scan: LaneScan) {
663 let mut roles = lock(&self.roles);
664 for (session, facts) in scan.sessions {
665 let role = roles.entry(session).or_default();
666 role.handled.extend(facts.handled);
667 for (key, parking) in facts.parked {
668 role.parked.entry(key).or_insert(parking);
669 }
670 let handled = role.handled.clone();
671 role.parked.retain(|key, _| !handled.contains(key));
672 role.terminal |= facts.terminal;
673 if let Some(ack) = facts.ack {
674 role.record_ack(ack);
675 }
676 }
677 }
678
679 async fn control_state(&self, session: ConversationId) -> Result<ControlState, LaserError> {
680 self.lens(session).control_state().await
681 }
682
683 fn lens(&self, session: ConversationId) -> Session {
684 let mut lens = self.sessions.open(session).as_agent(self.me.clone());
685 lens.control = Some(Arc::clone(&self.book));
686 lens
687 }
688
689 fn control_position(&self, at: ControlPosition) -> LogPosition {
690 LogPosition::new(self.control.0, self.control.1, at.0, at.1)
691 }
692
693 async fn acknowledge(
696 &self,
697 session: ConversationId,
698 state: TaskState,
699 request: PlacedRequest,
700 ) -> Result<(), LaserError> {
701 let acknowledged = lock(&self.roles)
702 .get(&session)
703 .and_then(|role| role.ack)
704 .is_some_and(|ack| ack.state == state && ack.request == request.at);
705 if acknowledged {
706 return Ok(());
707 }
708 let at = self.control_position(request.at);
709 let transition = SessionTransition {
710 actor: Some(self.me.clone()),
711 acknowledges: Some(at),
712 };
713 let lens = self.lens(session);
714 let lane = lens.lane()?;
715 let mut status = lane
716 .status(OPERATION_SESSION)
717 .with_task_state(state)
718 .body(encode_named(&transition)?)
719 .content_type(ContentType::Cbor);
720 if let Some(record) = request.record {
721 status = status.with_cause(record, Some(at));
722 }
723 let receipt = status.send_receipt().await?;
724 debug!(%session, %state, request = ?request.at, "acknowledged a session control request");
725 lock(&self.roles)
726 .entry(session)
727 .or_default()
728 .record_ack(Ack {
729 state,
730 request: request.at,
731 lane_offset: receipt.offset,
732 });
733 Ok(())
734 }
735
736 async fn acknowledge_resume(
739 &self,
740 session: ConversationId,
741 control: &ControlState,
742 ) -> Result<(), LaserError> {
743 let paused = !control.flags.cancel_requested
744 && lock(&self.roles)
745 .get(&session)
746 .and_then(|role| role.ack)
747 .is_some_and(|ack| ack.state == TaskState::Paused);
748 match control.resume {
749 Some(resume) if paused => self.acknowledge(session, TaskState::Working, resume).await,
750 _ => Ok(()),
751 }
752 }
753
754 async fn park(
757 &self,
758 session: ConversationId,
759 message: &AgentMessage,
760 pause: PlacedRequest,
761 ) -> Result<(), LaserError> {
762 let address = self.address(message);
763 let known = lock(&self.roles).get(&session).is_some_and(|role| {
764 role.parked
765 .keys()
766 .chain(role.handled.iter())
767 .any(|key| key.source == address)
768 });
769 if known {
770 debug!(%session, source = %message.id, "the record is already parked");
771 return Ok(());
772 }
773 let parking = SessionParking {
774 source: SourceRef::Message {
775 stream: address.stream,
776 topic: address.topic,
777 partition: address.partition,
778 offset: address.offset,
779 generation: address.generation,
780 conversation: Some(session.to_string()),
781 },
782 role: self.me.clone(),
783 request: self.control_position(pause.at),
784 };
785 let key = ParkingKey::of(&parking).expect("a parking names a log record");
786 let lens = self.lens(session);
787 let lane = lens.lane()?;
788 let mut event = lane
789 .emit(encode_named(&parking)?)
790 .with_operation(OPERATION_SESSION_PARKED)
791 .content_type(ContentType::Cbor);
792 if let Some(record) = message
793 .envelope
794 .as_ref()
795 .and_then(|envelope| envelope.record)
796 {
797 event = event.with_cause(
798 record,
799 Some(LogPosition::new(
800 address.stream,
801 address.topic,
802 address.partition,
803 address.offset,
804 )),
805 );
806 }
807 event.send_receipt().await?;
808 debug!(%session, source = %message.id, "parked a work record of a paused session");
809 lock(&self.roles)
810 .entry(session)
811 .or_default()
812 .parked
813 .insert(key, parking);
814 Ok(())
815 }
816
817 async fn drain(
822 &self,
823 worker: &dyn Replay,
824 session: ConversationId,
825 assigned_only: bool,
826 ) -> Result<Drained, LaserError> {
827 for (address, held) in self.pending(session) {
828 if assigned_only && !self.reads(address.partition) {
829 continue;
830 }
831 let control = self.control_state(session).await?;
832 if control.paused().is_some() || control.flags.cancel_requested {
833 return Ok(Drained::Interrupted);
834 }
835 let Some(received) = self.fetch_source(address).await? else {
836 warn!(%session, source = ?address, "a held record cannot be recovered: its source expired or its topic was recreated, it stays listed as held");
837 let mut roles = lock(&self.roles);
838 let role = roles.entry(session).or_default();
839 role.unrecoverable
840 .extend(held.iter().map(|(key, _)| key.clone()));
841 continue;
842 };
843 worker.replay(&received).await?;
844 let lens = self.lens(session);
845 let lane = lens.lane()?;
846 for (key, parking) in held {
847 lane.emit(encode_named(&parking)?)
848 .with_operation(OPERATION_SESSION_UNPARKED)
849 .content_type(ContentType::Cbor)
850 .send_receipt()
851 .await?;
852 let mut roles = lock(&self.roles);
853 let role = roles.entry(session).or_default();
854 role.parked.remove(&key);
855 role.handled.insert(key);
856 }
857 debug!(%session, source = ?address, "handled a held record after the resume");
858 }
859 Ok(Drained::All)
860 }
861
862 fn pending(
865 &self,
866 session: ConversationId,
867 ) -> BTreeMap<SourceAddress, Vec<(ParkingKey, SessionParking)>> {
868 let roles = lock(&self.roles);
869 let mut pending: BTreeMap<SourceAddress, Vec<(ParkingKey, SessionParking)>> =
870 BTreeMap::new();
871 let Some(role) = roles.get(&session) else {
872 return pending;
873 };
874 for (key, parking) in &role.parked {
875 if key.source.stream != self.source.stream_id
876 || key.source.topic != self.source.topic_id
877 || role.unrecoverable.contains(key)
878 {
879 continue;
880 }
881 pending
882 .entry(key.source)
883 .or_default()
884 .push((key.clone(), parking.clone()));
885 }
886 pending
887 }
888
889 fn address(&self, message: &AgentMessage) -> SourceAddress {
890 SourceAddress {
891 stream: self.source.stream_id,
892 topic: self.source.topic_id,
893 partition: message.id.partition_id,
894 offset: message.id.offset,
895 generation: Some(self.source.generation),
896 }
897 }
898
899 fn handled(&self, session: ConversationId, address: SourceAddress) -> bool {
900 lock(&self.roles)
901 .get(&session)
902 .is_some_and(|role| role.handled.iter().any(|key| key.source == address))
903 }
904
905 fn has_pending(&self, session: ConversationId) -> bool {
906 !self.pending(session).is_empty()
907 }
908
909 fn involved(&self, session: ConversationId) -> bool {
910 lock(&self.roles)
911 .get(&session)
912 .is_some_and(|role| role.ack.is_some() || !role.parked.is_empty())
913 }
914
915 fn reads(&self, partition: u32) -> bool {
916 lock(&self.assignment)
917 .as_ref()
918 .is_none_or(|assigned| assigned.contains(&partition))
919 }
920
921 async fn cancel_paused(&self, session: ConversationId, named: bool) {
924 let (involved, terminal) = lock(&self.roles)
925 .get(&session)
926 .map_or((false, false), |role| {
927 (role.ack.is_some() || !role.parked.is_empty(), role.terminal)
928 });
929 if terminal || !(named || involved) {
930 return;
931 }
932 match self.lens(session).cancel().await {
933 Ok(()) => {
934 lock(&self.roles).entry(session).or_default().terminal = true;
935 debug!(%session, "ended a paused session canceled, its held records stay unprocessed");
936 }
937 Err(error) => {
938 warn!(%error, %session, "failed to end a paused session canceled");
939 }
940 }
941 }
942
943 async fn fetch_source(
946 &self,
947 address: SourceAddress,
948 ) -> Result<Option<ConsumerMessage>, LaserError> {
949 if address.generation != Some(self.source.generation) {
950 return Ok(None);
951 }
952 let polled = self
953 .laser
954 .client()
955 .poll_messages(
956 &Identifier::numeric(address.stream)?,
957 &Identifier::numeric(address.topic)?,
958 Some(address.partition),
959 &iggy::prelude::Consumer::new(Identifier::named(PARKED_READER)?),
960 &PollingStrategy::offset(address.offset),
961 1,
962 false,
963 )
964 .await?;
965 Ok(polled
966 .messages
967 .into_iter()
968 .find(|message| message.header.offset == address.offset)
969 .map(|message| ConsumerMessage::of(message, address.partition, address.offset, None)))
970 }
971}
972
973pub(crate) struct Gate<'a> {
975 runtime: &'a PauseRuntime,
976 session: ConversationId,
977 guard: Option<tokio::sync::OwnedMutexGuard<()>>,
978}
979
980impl Drop for Gate<'_> {
981 fn drop(&mut self) {
982 drop(self.guard.take());
983 let mut gates = lock(&self.runtime.gates);
984 if gates
985 .get(&self.session)
986 .is_some_and(|mutex| Arc::strong_count(mutex) == 1)
987 {
988 gates.remove(&self.session);
989 }
990 }
991}
992
993pub(crate) async fn drive(
996 runtime: Arc<PauseRuntime>,
997 worker: Arc<dyn Replay>,
998 mut requests: mpsc::UnboundedReceiver<ConversationId>,
999 mut stop: oneshot::Receiver<()>,
1000) {
1001 let mut tasks = tokio::task::JoinSet::new();
1002 loop {
1003 tokio::select! {
1004 _ = &mut stop => break,
1005 next = requests.recv() => match next {
1006 Some(session) => {
1007 let runtime = Arc::clone(&runtime);
1008 let worker = Arc::clone(&worker);
1009 tasks.spawn(async move { runtime.reconcile(worker.as_ref(), session).await });
1010 }
1011 None => break,
1012 },
1013 Some(_) = tasks.join_next(), if !tasks.is_empty() => {}
1014 }
1015 }
1016 while tasks.join_next().await.is_some() {}
1017}
1018
1019#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1020enum Drained {
1021 All,
1022 Interrupted,
1023}
1024
1025const CONTROL_READER: &str = "laser-control-follower";
1027const LANE_READER: &str = "laser-session-recovery";
1028const PARKED_READER: &str = "laser-parked-reader";
1029
1030#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1031struct Ack {
1032 state: TaskState,
1033 request: ControlPosition,
1034 lane_offset: Option<u64>,
1035}
1036
1037#[derive(Default)]
1039struct RoleState {
1040 ack: Option<Ack>,
1042 parked: BTreeMap<ParkingKey, SessionParking>,
1044 handled: BTreeSet<ParkingKey>,
1046 unrecoverable: BTreeSet<ParkingKey>,
1048 terminal: bool,
1050}
1051
1052impl RoleState {
1053 fn record_ack(&mut self, ack: Ack) {
1055 if self
1056 .ack
1057 .is_some_and(|current| current.lane_offset > ack.lane_offset)
1058 {
1059 return;
1060 }
1061 self.ack = Some(ack);
1062 }
1063}
1064
1065#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1066struct SourceAddress {
1067 stream: u32,
1068 topic: u32,
1069 partition: u32,
1070 offset: u64,
1071 generation: Option<u64>,
1072}
1073
1074#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
1077struct ParkingKey {
1078 source: SourceAddress,
1079 role: String,
1080 request: [u8; 20],
1081}
1082
1083impl ParkingKey {
1084 fn of(parking: &SessionParking) -> Option<Self> {
1085 let SourceRef::Message {
1086 stream,
1087 topic,
1088 partition,
1089 offset,
1090 generation,
1091 ..
1092 } = parking.source
1093 else {
1094 return None;
1095 };
1096 Some(Self {
1097 source: SourceAddress {
1098 stream,
1099 topic,
1100 partition,
1101 offset,
1102 generation,
1103 },
1104 role: parking.role.as_str().to_owned(),
1105 request: parking.request.to_bytes(),
1106 })
1107 }
1108}
1109
1110#[derive(Default)]
1112struct LaneScan {
1113 sessions: HashMap<ConversationId, SessionFacts>,
1114 truncated: BTreeSet<u32>,
1116}
1117
1118#[derive(Default)]
1119struct SessionFacts {
1120 partition: Option<u32>,
1121 ack: Option<Ack>,
1122 parked: BTreeMap<ParkingKey, SessionParking>,
1123 handled: BTreeSet<ParkingKey>,
1124 terminal: bool,
1125}
1126
1127impl SessionFacts {
1128 fn pending(&self) -> BTreeMap<ParkingKey, SessionParking> {
1129 self.parked
1130 .iter()
1131 .filter(|(key, _)| !self.handled.contains(*key))
1132 .map(|(key, parking)| (key.clone(), parking.clone()))
1133 .collect()
1134 }
1135}
1136
1137impl LaneScan {
1138 fn complete_for(&self, session: ConversationId) -> bool {
1139 match self
1140 .sessions
1141 .get(&session)
1142 .and_then(|facts| facts.partition)
1143 {
1144 Some(partition) => !self.truncated.contains(&partition),
1145 None => self.truncated.is_empty(),
1146 }
1147 }
1148
1149 fn fold(
1152 &mut self,
1153 partition: u32,
1154 message: &IggyMessage,
1155 role: Option<&AgentId>,
1156 only: Option<ConversationId>,
1157 ) {
1158 let Ok(decoded) = decode_agent_record(message, features::NONE) else {
1159 return;
1160 };
1161 let Some(envelope) = decoded.envelope else {
1162 return;
1163 };
1164 let session: ConversationId = envelope.conversation.into();
1165 if only.is_some_and(|only| only != session) {
1166 return;
1167 }
1168 if only.is_some() {
1169 self.sessions.entry(session).or_default().partition = Some(partition);
1170 }
1171 match (envelope.kind, envelope.operation.as_deref()) {
1172 (AgentKind::Status, Some(OPERATION_SESSION)) => match envelope.task_state {
1173 Some(state) if state.is_terminal() => {
1174 if role.is_some_and(|role| &envelope.source == role) {
1175 self.sessions.entry(session).or_default().terminal = true;
1176 }
1177 }
1178 Some(state @ (TaskState::Paused | TaskState::Working)) => {
1179 let Some(role) = role else {
1180 return;
1181 };
1182 if let Ok(SessionTransition {
1183 actor: Some(actor),
1184 acknowledges: Some(request),
1185 }) = decode_named::<SessionTransition>(&envelope.body)
1186 && &actor == role
1187 {
1188 self.sessions.entry(session).or_default().ack = Some(Ack {
1189 state,
1190 request: (request.partition_id, request.offset),
1191 lane_offset: Some(message.header.offset),
1192 });
1193 }
1194 }
1195 _ => {}
1196 },
1197 (
1198 AgentKind::Event,
1199 Some(operation @ (OPERATION_SESSION_PARKED | OPERATION_SESSION_UNPARKED)),
1200 ) => {
1201 let Ok(parking) = decode_named::<SessionParking>(&envelope.body) else {
1202 return;
1203 };
1204 if role.is_some_and(|role| &parking.role != role) {
1205 return;
1206 }
1207 let Some(key) = ParkingKey::of(&parking) else {
1208 return;
1209 };
1210 let facts = self.sessions.entry(session).or_default();
1211 if operation == OPERATION_SESSION_PARKED {
1212 facts.parked.insert(key, parking);
1213 } else {
1214 facts.handled.insert(key);
1215 }
1216 }
1217 _ => {}
1218 }
1219 }
1220}
1221
1222async fn scan_lane(
1226 laser: &Laser,
1227 role: Option<&AgentId>,
1228 only: Option<ConversationId>,
1229) -> Result<LaneScan, LaserError> {
1230 let stream = Identifier::named(laser.stream_required()?)?;
1231 let topic = Identifier::named(AGENT_SESSIONS)?;
1232 let client = laser.client();
1233 let mut scan = LaneScan::default();
1234 let Some(details) = client.get_topic(&stream, &topic).await? else {
1235 return Ok(scan);
1236 };
1237 let reader = iggy::prelude::Consumer::new(Identifier::named(LANE_READER)?);
1238 for partition in 0..bounded_partitions(details.partitions_count) {
1239 let window = read_window(&client, &stream, &topic, &reader, partition).await?;
1240 if window.truncated {
1241 scan.truncated.insert(partition);
1242 }
1243 for message in &window.messages {
1244 scan.fold(partition, message, role, only);
1245 }
1246 }
1247 Ok(scan)
1248}
1249
1250struct Window {
1251 messages: Vec<IggyMessage>,
1252 next_offset: u64,
1253 truncated: bool,
1255}
1256
1257async fn read_window(
1259 client: &IggyClient,
1260 stream: &Identifier,
1261 topic: &Identifier,
1262 reader: &iggy::prelude::Consumer,
1263 partition: u32,
1264) -> Result<Window, LaserError> {
1265 let first = client
1266 .poll_messages(
1267 stream,
1268 topic,
1269 Some(partition),
1270 reader,
1271 &PollingStrategy::first(),
1272 1,
1273 false,
1274 )
1275 .await?
1276 .messages
1277 .first()
1278 .map(|message| message.header.offset);
1279 let Some(first) = first else {
1280 return Ok(Window {
1281 messages: Vec::new(),
1282 next_offset: 0,
1283 truncated: false,
1284 });
1285 };
1286 let start = tail_anchored_offset(client, stream, topic, reader, partition, first).await?;
1287 let batch = drain_partition(
1288 client,
1289 stream,
1290 topic,
1291 reader,
1292 DrainRange::open(partition, start),
1293 READ_BATCH,
1294 )
1295 .await?;
1296 Ok(Window {
1297 messages: batch.messages,
1298 next_offset: batch.next_offset,
1299 truncated: first > 0 || start > first,
1300 })
1301}
1302
1303fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
1304 mutex
1305 .lock()
1306 .unwrap_or_else(std::sync::PoisonError::into_inner)
1307}
1308
1309#[cfg(test)]
1310mod tests {
1311 use super::*;
1312
1313 fn parking(offset: u64, request: u64) -> SessionParking {
1314 SessionParking {
1315 source: SourceRef::Message {
1316 stream: 1,
1317 topic: 2,
1318 partition: 0,
1319 offset,
1320 generation: Some(9),
1321 conversation: None,
1322 },
1323 role: "worker".parse().expect("valid agent id"),
1324 request: LogPosition::new(1, 3, 0, request),
1325 }
1326 }
1327
1328 #[test]
1329 fn given_parkings_and_completions_when_folded_then_should_keep_only_unhandled_records_by_source()
1330 {
1331 let mut facts = SessionFacts::default();
1332 for (offset, request) in [(7, 1), (4, 1), (7, 2)] {
1333 let parking = parking(offset, request);
1334 facts
1335 .parked
1336 .insert(ParkingKey::of(&parking).expect("a log record"), parking);
1337 }
1338 facts
1339 .handled
1340 .insert(ParkingKey::of(&parking(4, 1)).expect("a log record"));
1341 let pending: Vec<(u64, u64)> = facts
1342 .pending()
1343 .keys()
1344 .map(|key| {
1345 (
1346 key.source.offset,
1347 LogPosition::from_bytes(key.request).offset,
1348 )
1349 })
1350 .collect();
1351 assert_eq!(pending, vec![(7, 1), (7, 2)]);
1352 }
1353
1354 #[test]
1355 fn given_acknowledgments_when_recorded_then_should_keep_the_latest_on_the_lane() {
1356 let mut role = RoleState::default();
1357 let ack = |state, offset| Ack {
1358 state,
1359 request: (0, offset),
1360 lane_offset: Some(offset),
1361 };
1362 role.record_ack(ack(TaskState::Working, 9));
1363 role.record_ack(ack(TaskState::Paused, 4));
1364 assert_eq!(role.ack.map(|ack| ack.state), Some(TaskState::Working));
1365 role.record_ack(ack(TaskState::Paused, 12));
1366 assert_eq!(role.ack.map(|ack| ack.state), Some(TaskState::Paused));
1367 }
1368
1369 #[test]
1370 fn given_lane_operations_when_classified_then_should_count_only_work_commands() {
1371 assert!(is_work("invoke_agent"));
1372 assert!(is_work(""));
1373 assert!(!is_work(OPERATION_CHAT));
1374 assert!(!is_work(OPERATION_EXECUTE_TOOL));
1375 assert!(!is_work(laser_wire::dispatch::OPERATION_SESSION_PAUSE));
1376 }
1377}