Skip to main content

laser_sdk/agent/
pause.rs

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/// The held work of one session that no agent reported handled, read by
39/// [`Session::parked`].
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ParkedRecords {
42    /// The held records, by source position.
43    pub records: Vec<SessionParking>,
44    /// False when the bounded read of `agent.sessions` did not reach the
45    /// oldest retained record of the session's partition, so older held
46    /// records may be missing, or when a held record can no longer be read
47    /// back because its source expired or its topic was recreated.
48    pub complete: bool,
49}
50
51impl Session {
52    /// The work records this session's agents held while it was paused and
53    /// have not reported handled. A session canceled while paused keeps its
54    /// held records here unprocessed. The read covers the newest
55    /// [`CONTEXT_READ_WINDOW`](crate::context::CONTEXT_READ_WINDOW) records
56    /// of each `agent.sessions` partition.
57    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
79// Whether held records can still be read back from their sources, with the
80// topic generations and partition starts looked up once each.
81struct 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
141/// The agents the lane of `session` shows working on it: the addressees of
142/// its work commands and the agents that picked it up or acknowledged a
143/// control request. Model, tool, and control records are not work.
144pub(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
187/// Handles a held record again after the resume, through the same decode,
188/// gates, handler, retry, and dead letter as live delivery, but without the
189/// pause check. `Err` only when a dead letter could not be published.
190pub(crate) trait Replay: Send + Sync {
191    fn replay<'a>(&'a self, received: &'a ConsumerMessage)
192    -> BoxFuture<'a, Result<(), LaserError>>;
193}
194
195/// What the pause check decided for one work record.
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub(crate) enum Hold {
198    /// Handle the record now.
199    Handle,
200    /// Commit the record without handling it: it is durably parked, or it
201    /// was already handled through its parking.
202    Commit,
203}
204
205/// Where a runtime's work records come from.
206#[derive(Debug, Clone, Copy)]
207pub(crate) struct SourceTopic {
208    pub(crate) stream_id: u32,
209    pub(crate) topic_id: u32,
210    /// The topic's creation time, which proves the numeric topic id.
211    pub(crate) generation: u64,
212}
213
214/// `agent.control` as the startup read found it: its ids and the next offset
215/// to follow on each partition.
216pub(crate) struct ControlFeed {
217    pub(crate) stream_id: u32,
218    pub(crate) topic_id: u32,
219    // The creation time of the control topic the feed reads.
220    topic_gen: u64,
221    next: BTreeMap<u32, u64>,
222    /// Some partition holds more than the bounded read covered, or already
223    /// expired records, so requests older than the read may be missing.
224    truncated: bool,
225}
226
227/// Read the newest records of every `agent.control` partition into `book`
228/// and return where to follow from, `None` when the stream has no control
229/// topic. The book is not marked live here.
230pub(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
281/// The control follower: polls every `agent.control` partition from where
282/// the startup read ended and records each request for `me` in the book, so
283/// every instance of a role sees every request whatever partitions its
284/// consumer group assigns it. Each request is handed to the pause runtime
285/// through `notify`. When the follower stops, the book stops claiming to be
286/// current and every read folds the log.
287/// The longest wait between looks for an `agent.control` topic that did not
288/// exist when the consumer started.
289use crate::retry::CONTROL_DISCOVERY_MAX;
290
291/// Wait for `agent.control` to appear, then follow it. A stream that gets its
292/// control topic after the consumer started still receives operator
293/// requests. Until then the book reports control as unavailable.
294/// Starts the pause runtime once a control topic that was missing at start
295/// appears, and returns where the follower reports each request.
296pub(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            // A recreated agent.control starts every session's control
409            // history again, so the book starts again from the new topic.
410            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
438/// How often an idle control follower checks that agent.control is still the
439/// topic it reads.
440const 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
465/// The pause and resume runtime of one agent role (D7a). Work for a paused
466/// session is durably parked on the session lane before its source offset is
467/// committed, the role acknowledges each pause and resume request it takes
468/// part in on the lane, and a resume handles the held work again before new
469/// work for the session. Every decision is rebuilt from the log after a
470/// restart, a rebalance, or a reopened consumer.
471pub(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    /// Serialize the work and control handling of one session in this
516    /// runtime. A pause acknowledgment taken under the gate comes after every
517    /// record of the session already in flight.
518    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    /// Decide what to do with one work record of a session, under its gate.
529    /// A paused session acknowledges the pause and parks the record. A
530    /// resumed session acknowledges the resume and handles its held records
531    /// first. A failed acknowledgment, parking, or completion is an error, so
532    /// the record stays uncommitted.
533    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            // A record redelivered after a crash between its parking and its
558            // commit was just handled through the parking.
559            return Ok(if self.handled(session, self.address(message)) {
560                Hold::Commit
561            } else {
562                Hold::Handle
563            });
564        }
565    }
566
567    /// Bring one session up to date with its control requests outside the
568    /// work path: acknowledge a pause the role is named in or already takes
569    /// part in, end a paused session canceled, acknowledge a resume, and
570    /// handle held records on partitions this member reads.
571    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    /// Run the recovery read before the first dispatch.
579    pub(crate) async fn recover(&self) -> Result<(), LaserError> {
580        self.ensure_recovered().await
581    }
582
583    /// Note the partitions this member reads. A changed assignment means
584    /// another member may have written held work for a session this member
585    /// now reads, so the next dispatch reads the lane again.
586    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    /// Read the lane again before the next dispatch, after the consumer was
603    /// reopened.
604    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    // The bounded recovery read of the lane, once per assignment epoch, and
631    // only when some session has a pause history or the control read could
632    // not prove there is none.
633    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    // Write `status(session, state)` acknowledging `request`, unless this
694    // role's newest acknowledgment already is that one.
695    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    // Acknowledge the resume when this role acknowledged a pause it lifts,
737    // unless the session was canceled.
738    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    // Append and confirm the parking record of `message` under `pause`. A
755    // source this role already parked or handled is not parked again.
756    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    // Handle the held records of `session` once each, oldest source first,
818    // and confirm a completion for each. Stops when the session is paused or
819    // canceled again. A record whose source expired or whose topic was
820    // recreated is reported and left held.
821    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    // The held records of `session` this runtime can handle: from its own
863    // source topic and not already found unrecoverable, grouped by source.
864    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    // End a paused session canceled as this role, once. Best effort: a
922    // failed write is retried by the next record or request.
923    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    // Read one held record back from its source. `None` when the record no
944    // longer exists there or the topic was recreated since it was parked.
945    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
973/// The per-session gate of a [`PauseRuntime`].
974pub(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
993/// The pause driver: brings each session a control request names up to
994/// date, one task per request, until `stop` fires.
995pub(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
1025// The consumer name the bounded reads poll under. Reads never commit.
1026const 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/// What one role knows of one session's pause history.
1038#[derive(Default)]
1039struct RoleState {
1040    /// The role's newest acknowledgment.
1041    ack: Option<Ack>,
1042    /// Held records without a completion.
1043    parked: BTreeMap<ParkingKey, SessionParking>,
1044    /// Held records with a completion.
1045    handled: BTreeSet<ParkingKey>,
1046    /// Held records whose source cannot be read back. They stay listed.
1047    unrecoverable: BTreeSet<ParkingKey>,
1048    /// The role wrote a terminal status.
1049    terminal: bool,
1050}
1051
1052impl RoleState {
1053    // Keep the acknowledgment latest on the lane.
1054    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/// The identity of one parking: the source address, the role, and the pause
1075/// request position.
1076#[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/// The pause facts a bounded read of `agent.sessions` found.
1111#[derive(Default)]
1112struct LaneScan {
1113    sessions: HashMap<ConversationId, SessionFacts>,
1114    /// Partitions whose read did not reach their oldest retained record.
1115    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    // Fold one lane record. `role` keeps only that role's facts. `only`
1150    // keeps only one session and notes its partition.
1151    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
1222// Read the newest records of every `agent.sessions` partition, up to the
1223// context read window, and fold the pause facts of `role` (every role when
1224// `None`) for `only` (every session when `None`).
1225async 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    /// The partition holds or held records before the window.
1254    truncated: bool,
1255}
1256
1257// The newest records of one partition, up to the context read window.
1258async 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}