Skip to main content

laser_sdk/agent/
consumer.rs

1use crate::agent::budget::BudgetGate;
2use crate::agent::clock::{Clock, SystemClock};
3use crate::agent::control::ControlBook;
4use crate::agent::ctx::AgentCtx;
5use crate::agent::pause::{Hold, PauseRuntime, Replay, SourceTopic};
6use crate::agent::session::{SessionConfig, Sessions};
7use crate::capabilities::HelloOutcome;
8use crate::error::LaserError;
9use crate::laser::Laser;
10use crate::provenance::{AgentTopic, LlmUsage, Provenance};
11use crate::stream::{CommitPolicy, Consumer, ConsumerMessage, Headers};
12use crate::types::{AgentId, ConsumerGroupName, ConversationId, MessageId};
13use async_trait::async_trait;
14use futures::FutureExt;
15use iggy::prelude::*;
16use laser_wire::agent::{
17    AgentDeadLetter, AgentEnvelope, AgentErrorBody, AgentErrorCode, AgentKind, DeadLetterReason,
18    LogPosition, OPERATION_TASK, SignatureContext, TaskState, features, validate,
19};
20use laser_wire::codes::AGENT_OP_VERSION;
21use laser_wire::content::ContentType;
22use laser_wire::dispatch::{
23    Dispatch, HandledOperations, addressee_filter, classify, classify_generic,
24};
25use laser_wire::framing::{decode_named, encode_named};
26use laser_wire::headers::{AGENT_VERSION, CONTENT_TYPE, CONVERSATION_ID, FENCE};
27use laser_wire::topics::{AGENT_CONTROL, AGENT_SESSIONS};
28use std::collections::{BTreeMap, HashSet, VecDeque};
29use std::str::FromStr;
30use std::sync::{Arc, Mutex};
31use std::time::Duration;
32use tokio::sync::oneshot;
33use tokio::time::sleep;
34use tracing::{debug, error, warn};
35
36// Capped exponential backoff between consecutive poll failures, jittered so
37// consumers that failed together do not reopen together. See `crate::retry`.
38pub(crate) fn backoff_for(attempt: u32) -> Duration {
39    crate::retry::CONSUMER.delay(attempt)
40}
41
42/// The composed-dedup and fence-map tuning constants, grouped near the top so
43/// the consume path below reads without stopping at a definition.
44/// Separator between the principal and the idempotency key in the composed dedup
45/// key (ASCII unit separator, which cannot appear in an agent id).
46const DEDUP_SCOPE_SEP: char = '\u{1f}';
47
48/// The most fence high-water entries kept before an idle-eviction sweep is
49/// considered, so a long-lived consumer's per-task fence map stays bounded by the
50/// recently-active working set rather than every task ever seen.
51const FENCE_MAP_SOFT_CAP: usize = 16_384;
52
53/// How many verified record ids a consumer remembers, to refuse a replay of the
54/// exact signed bytes. Sized like the fence map: bounded by the recently-active
55/// working set, not by every record ever seen.
56#[cfg(feature = "sign")]
57const VERIFIED_RECORD_WINDOW: usize = 16_384;
58
59/// A fence entry untouched for this long is swept once the map is over its soft
60/// cap. The gate is kept for any task active within the window. Only tasks long
61/// idle (where a stale-holder replay is no longer plausible, and dedup is the
62/// backstop) are dropped.
63const FENCE_ENTRY_TTL_MICROS: u64 = 600_000_000;
64
65/// The least time between idle-eviction sweeps, so the O(n) `retain` runs at most
66/// this often under load instead of on every accepted fence.
67const FENCE_SWEEP_INTERVAL_MICROS: u64 = 1_000_000;
68
69// Iggy defaults to a one-second poll, tuned for throughput. An agent runtime is
70// latency-bound, so each hop would wait up to a second. Override per agent with
71// `Agent::builder().poll_interval(..)`.
72const POLL_INTERVAL: Duration = Duration::from_millis(10);
73
74// How long a graceful shutdown waits for the in-flight message (its handler and
75// any retry backoff) to finish before dropping the consumer. `abort` is the
76// unconditional hard stop, this bounds the polite one.
77const DEFAULT_SHUTDOWN_GRACE: Duration = Duration::from_secs(30);
78const DEFAULT_MAX_QUEUED_RECORDS: usize = 4_096;
79const DEFAULT_MAX_QUEUED_BYTES: usize = 64 * 1024 * 1024;
80
81/// What you implement: one async `handle` per message. (`AgentHandler` is the `Send` variant the runtime drives.)
82#[trait_variant::make(AgentHandler: Send)]
83pub trait LocalAgentHandler {
84    async fn handle(&self, message: &AgentMessage, ctx: &AgentCtx<'_>) -> Result<(), LaserError>;
85}
86
87/// A cross-cutting hook wrapped around every handler dispatch, for auth, metrics,
88/// and tracing without a handler rewrite. `before_handle` runs once before the
89/// retry loop and may reject the message (a rejection dead-letters it without
90/// running the handler). `after_handle` runs after every attempt with its result
91/// and one-based attempt number, so a metrics sink counts retries and outcomes.
92/// Boxed-future (`#[async_trait]`) so it composes as `Arc<dyn AgentMiddleware>`,
93/// the same seam shape as [`Deduplicator`].
94#[async_trait]
95pub trait AgentMiddleware: Send + Sync {
96    async fn before_handle(&self, message: &AgentMessage) -> Result<(), LaserError> {
97        let _ = message;
98        Ok(())
99    }
100
101    async fn after_handle(
102        &self,
103        message: &AgentMessage,
104        result: &Result<(), LaserError>,
105        attempt: u32,
106    ) {
107        let _ = (message, result, attempt);
108    }
109}
110
111/// Notified whenever the consumer produces a dead-letter capsule, with the
112/// capsule and the result of publishing it to the DLQ topic. A publish failure
113/// means the poison message is lost as its offset commits, so this is the seam an
114/// operator wires to alert on a lost message rather than grep logs. The `message`
115/// is present for a decoded poison message, absent when the provenance itself
116/// would not decode. Boxed-future (`#[async_trait]`) so it composes as
117/// `Arc<dyn DeadLetterSink>`.
118#[async_trait]
119pub trait DeadLetterSink: Send + Sync {
120    async fn on_dead_letter(
121        &self,
122        message: Option<&AgentMessage>,
123        capsule: &AgentDeadLetter,
124        publish_result: &Result<(), LaserError>,
125    );
126}
127
128/// A message delivered to a handler: decoded provenance, raw payload, and log position.
129#[derive(Debug, Clone)]
130pub struct AgentMessage {
131    /// Provenance headers decoded off the message. For an AGDX message it is
132    /// synthesized from the decoded [`envelope`](Self::envelope), so routing,
133    /// dedup, and deadline work uniformly for both message shapes.
134    pub provenance: Provenance,
135    /// The raw message body. Owned `Vec<u8>` so the public API never leaks the
136    /// `bytes` crate. Decode it with whatever codec the producer used.
137    pub payload: Vec<u8>,
138    /// Where the message sits on the log (partition and offset).
139    pub id: MessageId,
140    /// The decoded AGDX envelope when the message carries one (the `agdx.av`
141    /// header is present). `None` for a plain `send_agent` message.
142    pub envelope: Option<AgentEnvelope>,
143    /// The `agdx.ct` content-type header when stamped (what the
144    /// [`body`](Self::body) bytes are), `None` when the producer stamped none.
145    /// `ContentType::Ref` marks a claim-checked body, resolved with
146    /// [`resolve_body`](Self::resolve_body).
147    pub content_type: Option<ContentType>,
148    /// The principal returned by enrolled signature verification. Set on
149    /// contract replies accepted through a verifier, otherwise `None`.
150    pub verified_principal: Option<String>,
151}
152
153impl AgentMessage {
154    /// The task body, regardless of message shape: the AGDX envelope's `body` when
155    /// the message is an AGDX command/response (its [`payload`](Self::payload) is
156    /// the encoded envelope, not the body), otherwise the raw `payload`. A handler
157    /// uses this so it does not have to know whether it was reached by a `contract`
158    /// or workflow (AGDX) or a plain `send_agent`.
159    pub fn body(&self) -> &[u8] {
160        match &self.envelope {
161            Some(envelope) => &envelope.body,
162            None => &self.payload,
163        }
164    }
165
166    // Decode a delivered record into an `AgentMessage`. On a decode failure
167    // the payload rides back in the error so the caller can dead-letter it
168    // verbatim.
169    fn from_consumer(
170        received: &ConsumerMessage,
171        understood_features: u64,
172    ) -> Result<DecodedAgentMessage, (Box<LaserError>, Vec<u8>)> {
173        let id = received.position;
174        let payload = received.payload.to_vec();
175        if received.headers_malformed {
176            return Err((
177                Box::new(LaserError::Invalid(
178                    "the record's header block does not decode".to_owned(),
179                )),
180                payload,
181            ));
182        }
183        let decoded = match decode_record_parts(
184            &received.headers,
185            &received.payload,
186            received.timestamp_micros,
187            understood_features,
188        ) {
189            Ok(decoded) => decoded,
190            Err(error) => return Err((Box::new(error), payload)),
191        };
192        Ok(DecodedAgentMessage {
193            message: Self {
194                provenance: decoded.provenance,
195                payload,
196                id,
197                envelope: decoded.envelope,
198                content_type: decoded.content_type,
199                verified_principal: None,
200            },
201            #[cfg(feature = "sign")]
202            signature_context: decoded.signature_context,
203            #[cfg(feature = "sign")]
204            observed_at_micros: decoded.observed_at_micros,
205        })
206    }
207}
208
209struct DecodedAgentMessage {
210    message: AgentMessage,
211    #[cfg(feature = "sign")]
212    signature_context: Option<SignatureContext>,
213    #[cfg(feature = "sign")]
214    observed_at_micros: u64,
215}
216
217pub(crate) struct DecodedAgentRecord {
218    pub(crate) provenance: Provenance,
219    pub(crate) envelope: Option<AgentEnvelope>,
220    pub(crate) content_type: Option<ContentType>,
221    pub(crate) signature_context: Option<SignatureContext>,
222    pub(crate) observed_at_micros: u64,
223}
224
225pub(crate) fn decode_agent_record(
226    message: &IggyMessage,
227    understood_features: u64,
228) -> Result<DecodedAgentRecord, LaserError> {
229    let headers = message.user_headers_map()?.unwrap_or_default();
230    decode_record_parts(
231        &headers,
232        &message.payload,
233        message.header.timestamp,
234        understood_features,
235    )
236}
237
238// Decode one record from its header map, payload, and broker timestamp.
239fn decode_record_parts(
240    headers: &Headers,
241    payload: &[u8],
242    observed_at_micros: u64,
243    understood_features: u64,
244) -> Result<DecodedAgentRecord, LaserError> {
245    let content_type_key = HeaderKey::from_str(CONTENT_TYPE)?;
246    let content_type_code = headers
247        .get(&content_type_key)
248        .map(HeaderValue::as_uint8)
249        .transpose()?;
250    let content_type = content_type_code.and_then(ContentType::from_code);
251    let version_key = HeaderKey::from_str(AGENT_VERSION)?;
252    let version = headers
253        .get(&version_key)
254        .map(HeaderValue::as_uint32)
255        .transpose()?;
256    let (provenance, envelope, signature_context) = match version {
257        Some(version) => {
258            if version != AGENT_OP_VERSION {
259                return Err(LaserError::Invalid(format!(
260                    "unsupported agent envelope version {version}"
261                )));
262            }
263            let envelope: AgentEnvelope = decode_named(payload)?;
264            validate(&envelope)?;
265            let unmet = envelope.unmet_requirements(understood_features);
266            if unmet != features::NONE {
267                return Err(LaserError::Invalid(format!(
268                    "agent envelope requires unsupported features 0x{unmet:016x}"
269                )));
270            }
271            let provenance = provenance_from_envelope(&envelope);
272            let context = SignatureContext {
273                content_type: content_type_code,
274                agent_version: Some(version),
275            };
276            (provenance, Some(envelope), Some(context))
277        }
278        None => (
279            crate::provenance::provenance_from_headers(headers)?,
280            None,
281            None,
282        ),
283    };
284    Ok(DecodedAgentRecord {
285        provenance,
286        envelope,
287        content_type,
288        signature_context,
289        observed_at_micros,
290    })
291}
292
293// Decode a log message into its runtime provenance and, when it is an AGDX
294// message (the `agdx.av` header is present), its envelope. An AGDX message routes
295// off the decoded envelope, whose typed fields the string-header provenance
296// decoder cannot read. Everything else routes off the provenance headers. The
297// read paths (the reliable consumer, context assembly, the stream reader) share
298// this so AGDX and `send_agent` messages decode identically everywhere.
299pub(crate) fn provenance_and_envelope(
300    message: &IggyMessage,
301) -> Result<(Provenance, Option<AgentEnvelope>), LaserError> {
302    let decoded = decode_agent_record(message, features::NONE)?;
303    Ok((decoded.provenance, decoded.envelope))
304}
305
306// The conversation a record's header names, in either encoding the wire
307// allows, when the rest of the record does not decode.
308fn original_conversation(headers: &Headers) -> Option<ConversationId> {
309    let value = headers.get(&HeaderKey::from_str(CONVERSATION_ID).ok()?)?;
310    match value.kind() {
311        HeaderKind::Uint128 => {
312            Some(laser_wire::agent::ConversationId::from_u128(value.as_uint128().ok()?).into())
313        }
314        _ => value.as_str().ok()?.parse().ok(),
315    }
316}
317
318// Synthesize the runtime provenance from an AGDX envelope, so the consumer's
319// target filter, dedup, and deadline checks read one shape for both message
320// kinds. Agent ids are name strings on both sides, so `source`/`target` map
321// straight across, and a name the SDK validator rejects simply drops out.
322fn provenance_from_envelope(envelope: &AgentEnvelope) -> Provenance {
323    Provenance::builder()
324        .conversation_id(envelope.conversation.into())
325        .maybe_parent_conversation_id(envelope.parent.map(Into::into))
326        .maybe_root_conversation_id(envelope.root.map(Into::into))
327        .maybe_agent(AgentId::try_from(envelope.source.as_str()).ok())
328        .maybe_target_agent_id(
329            envelope
330                .target
331                .as_ref()
332                .and_then(|target| AgentId::try_from(target.as_str()).ok()),
333        )
334        .maybe_idempotency_key(
335            envelope
336                .idempotency_key
337                .as_ref()
338                .map(|key| key.as_str().to_owned()),
339        )
340        .maybe_correlation_id(
341            envelope
342                .correlation
343                .map(|correlation| correlation.to_string()),
344        )
345        .maybe_deadline(envelope.deadline_micros.map(IggyTimestamp::from))
346        .maybe_usage(envelope.usage.map(|usage| LlmUsage {
347            input_tokens: Some(usage.input_tokens),
348            output_tokens: Some(usage.output_tokens),
349            cost_usd: None,
350        }))
351        // An enveloped fenced effect carries the fence as the `agdx.fence`
352        // metadata key, so the consumer gate reads it the same way it reads the
353        // header on a generic-provenance message.
354        .maybe_fence_token(envelope.metadata.as_ref().and_then(
355            |metadata| match metadata.get(FENCE) {
356                Some(laser_wire::query::Value::Uint(fence)) => Some(*fence),
357                Some(laser_wire::query::Value::Int(fence)) => u64::try_from(*fence).ok(),
358                _ => None,
359            },
360        ))
361        .build()
362}
363
364/// How the reliable consumer retries a transient handler error: capped attempts with exponential backoff.
365#[derive(Debug, Clone, Copy)]
366pub struct RetryPolicy {
367    /// Total attempts before dead-lettering.
368    pub max_attempts: u32,
369    /// First backoff delay, doubled each attempt.
370    pub base_delay: Duration,
371}
372
373impl RetryPolicy {
374    /// A policy of `max_attempts` with exponential backoff from `base_delay`.
375    pub fn backoff(max_attempts: u32, base_delay: Duration) -> Self {
376        Self {
377            max_attempts,
378            base_delay,
379        }
380    }
381
382    fn delay_for(&self, attempt: u32) -> Duration {
383        self.base_delay
384            .saturating_mul(2u32.saturating_pow(attempt.min(16)))
385    }
386}
387
388impl Default for RetryPolicy {
389    fn default() -> Self {
390        Self {
391            max_attempts: 5,
392            base_delay: Duration::from_millis(200),
393        }
394    }
395}
396
397/// How the reliable consumer schedules message handling across partitions.
398#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
399pub enum ConcurrencyPolicy {
400    /// One message at a time across every partition (the safe, ordered default).
401    /// A slow or retrying message holds the member until it finishes.
402    #[default]
403    Serial,
404    /// One worker lane per partition: messages from different partitions run
405    /// concurrently, but a single partition is still handled strictly in order,
406    /// one at a time. Retry backoff is lane-local, so a poison message on one
407    /// partition never stalls another. `max_partitions` bounds the number of
408    /// concurrent lanes (a message for a partition beyond the cap is handled
409    /// inline rather than dropped).
410    SerialPerPartition {
411        /// The most concurrent lanes to run.
412        max_partitions: usize,
413    },
414}
415
416/// The reliable consumer (consumer-group delivery + dedup + retry + DLQ). Most callers use `Agent::builder`, not this directly.
417#[derive(bon::Builder)]
418pub struct ReliableConsumer {
419    pub group: ConsumerGroupName,
420    /// Logical identity used for target filtering and replies. It is never
421    /// inferred from the deployment group name.
422    pub agent: Option<AgentId>,
423    #[builder(into)]
424    pub topic: String,
425    #[builder(default = 10_000)]
426    pub dedup_window: usize,
427    #[builder(default)]
428    pub retry: RetryPolicy,
429    /// Must-understand AGDX feature bits implemented by this receiver. Messages
430    /// requiring any other bit are dead-lettered before dispatch.
431    #[builder(default = features::NONE)]
432    pub understood_features: u64,
433    /// Poll interval, default a reactive 10ms. Raise for throughput-bound work.
434    #[builder(default = POLL_INTERVAL)]
435    pub poll_interval: Duration,
436    /// How long a graceful shutdown waits for the in-flight message to finish
437    /// before dropping the consumer. `run` returns [`LaserError::Timeout`] if the
438    /// grace elapses with a message still in flight. Default 30s.
439    #[builder(default = DEFAULT_SHUTDOWN_GRACE)]
440    pub shutdown_grace: Duration,
441    /// How message handling is scheduled across partitions. Defaults to
442    /// [`ConcurrencyPolicy::Serial`] (strict one-at-a-time).
443    #[builder(default)]
444    pub concurrency: ConcurrencyPolicy,
445    /// Maximum records buffered across partition lanes.
446    #[builder(default = DEFAULT_MAX_QUEUED_RECORDS)]
447    pub max_queued_records: usize,
448    /// Maximum payload and header bytes buffered across partition lanes.
449    #[builder(default = DEFAULT_MAX_QUEUED_BYTES)]
450    pub max_queued_bytes: usize,
451    pub respond_on: Option<AgentTopic<'static>>,
452    /// Default inbox route for the ctx's directed-send and fan-out helpers.
453    #[builder(default)]
454    pub inbox_route: crate::agent::router::InboxRoute,
455    /// Emit a `Working` task status on `respond_on` the moment an AGDX command is
456    /// picked up, before the handler runs, so a [`contract`](crate::laser::Laser::contract)
457    /// caller can tell the command was consumed (versus expired unconsumed). Off by
458    /// default. Requires `respond_on` and a valid agent id.
459    #[builder(default)]
460    pub ack_on_pickup: bool,
461    // Override the dedup backend. Defaults to an in-memory `SlidingWindow` of
462    // `dedup_window` keys, and a durable backend is a drop-in via this seam.
463    pub deduplicator: Option<Box<dyn Deduplicator>>,
464    // Replay the partition tail into the dedup window on startup so a restart does
465    // not reprocess duplicates that are still inside the window. Off by default
466    // (the at-least-once + idempotent-handler default tolerates the replay).
467    #[builder(default)]
468    pub warm_dedup: bool,
469    /// Cross-cutting hooks wrapped around each handler dispatch, in order, for
470    /// auth, metrics, and tracing without touching the handler.
471    #[builder(default)]
472    pub middleware: Vec<std::sync::Arc<dyn AgentMiddleware>>,
473    /// Notified on every dead-letter with the result of publishing it, so a lost
474    /// poison message (a DLQ publish failure) is an observable event, not a log line.
475    pub on_dead_letter: Option<std::sync::Arc<dyn DeadLetterSink>>,
476    /// The command operations the handler serves. `None` serves every
477    /// operation. A command for another operation is reported as skipped,
478    /// so a model or tool record an agent writes for itself never becomes its
479    /// own work.
480    pub operations: Option<Vec<String>>,
481    /// The session configuration the runtime applies: the session lens
482    /// handlers read through `AgentCtx::session`, and
483    /// [`SessionConfig::fail_on_dead_letter`]. Defaults to
484    /// [`SessionConfig::default`].
485    pub sessions: Option<SessionConfig>,
486    /// When set, every message's envelope signature is verified against this
487    /// registry before dispatch, and an unsigned or unverified record is
488    /// dead-lettered. Set it on control and effect topics, where verification is
489    /// mandatory (the enforcement chokepoint for authorship and authorization).
490    #[cfg(feature = "sign")]
491    pub verifier: Option<std::sync::Arc<crate::sign::KeyRegistry>>,
492    /// This consumer's signing identity, threaded into `AgentCtx` so `respond`
493    /// answers correlated commands with signed AGDX responses.
494    #[cfg(feature = "sign")]
495    pub signing_key: Option<std::sync::Arc<crate::sign::SigningKey>>,
496}
497
498impl ReliableConsumer {
499    /// Consume until `shutdown` fires, dispatching each message to `handler`.
500    /// `ready` fires once the consumer has joined its group and is polling.
501    ///
502    /// Delivery runs on [`ConsumerGroup::consumer`](crate::stream::ConsumerGroup::consumer),
503    /// so a server that resolves group policies reads through the group-aware
504    /// engine. On `agent.sessions` and `agent.control`, when the server serves
505    /// filtered reads and the filter catalog, the group is bound to the
506    /// addressee filter `agdx.to In [<agent>, "*"]` before it reads. A group
507    /// bound to another filter is refused. On open Apache Iggy the group
508    /// stays unbound and records are classified on the client. A capability
509    /// probe that established nothing is an error.
510    pub async fn run<H>(
511        mut self,
512        laser: &Laser,
513        handler: H,
514        ready: oneshot::Sender<()>,
515        shutdown: oneshot::Receiver<()>,
516    ) -> Result<(), LaserError>
517    where
518        H: AgentHandler + Sync + Send + 'static,
519    {
520        let stream = laser.stream_required()?.to_owned();
521        let engine = DeliveryEngine::resolve(laser).await?;
522        let me = self.agent.as_ref().map(AgentId::wire_id);
523        if let Some(me) = &me {
524            engine
525                .bind_addressee(laser, &stream, &self.topic, self.group.as_str(), me)
526                .await?;
527        }
528        let opener = Opener {
529            laser: laser.clone(),
530            stream: stream.clone(),
531            topic: self.topic.clone(),
532            group: self.group.as_str().to_owned(),
533            poll_interval: self.poll_interval,
534            native: engine.native,
535        };
536        let consumer = opener.open().await?;
537
538        let deduplicator = self
539            .deduplicator
540            .take()
541            .unwrap_or_else(|| Box::new(SlidingWindow::new(self.dedup_window)));
542        if self.warm_dedup {
543            warm_dedup_window(laser, &self, deduplicator.as_ref()).await?;
544        }
545        // Resolve the subscribed stream and topic to their numeric ids once, so
546        // every dead-letter capsule can carry a complete `LogPosition` for the
547        // poison message without a server round-trip per failure. The consumer has
548        // already joined this stream/topic, so a missing id is a should-never
549        // happen: warn loudly rather than silently stamping a wrong locator -
550        // the partition and offset (the locate-within-topic half) stay correct.
551        let stream_ident = Identifier::named(&stream)?;
552        let topic_ident = Identifier::named(&self.topic)?;
553        let stream_id = laser
554            .client()
555            .get_stream(&stream_ident)
556            .await?
557            .map(|details| details.id);
558        let topic_details = laser
559            .client()
560            .get_topic(&stream_ident, &topic_ident)
561            .await?;
562        let topic_id = topic_details.as_ref().map(|details| details.id);
563        let topic_generation = topic_details
564            .as_ref()
565            .map_or(0, |details| details.created_at.as_micros());
566        if stream_id.is_none() || topic_id.is_none() {
567            warn!(
568                topic = %self.topic,
569                "could not resolve the numeric stream/topic id, dead-letter capsules \
570                 carry 0 for the unresolved locator half (partition and offset stay correct)"
571            );
572        }
573        let source = match (stream_id, topic_id) {
574            (Some(stream_id), Some(topic_id)) => Some(SourceTopic {
575                stream_id,
576                topic_id,
577                generation: topic_generation,
578            }),
579            _ => None,
580        };
581        let (stream_id, topic_id) = (stream_id.unwrap_or_default(), topic_id.unwrap_or_default());
582        let sessions = laser.sessions_with(self.sessions.clone().unwrap_or_default());
583
584        // The control subscription: a bounded read of `agent.control` loads
585        // the requests already on the log, and the follower reads every
586        // partition from where it ended, so each instance of the role sees
587        // every pause, resume, and cancel request whatever partitions its
588        // group assigns it. Handlers read the requests through their session
589        // lens. An agent with an id also runs the pause runtime. A stream
590        // without the topic has no operator control.
591        let control = Arc::new(ControlBook::default());
592        let (reconcile, reconcile_requests) = tokio::sync::mpsc::unbounded_channel();
593        let mut pause = None;
594        let mut late_control = false;
595        let mut control_follower = if self.topic == AGENT_CONTROL {
596            None
597        } else {
598            match crate::agent::pause::load_control(laser, me.as_ref(), &control).await? {
599                Some(feed) => {
600                    match (&me, source) {
601                        (Some(me), Some(source)) => {
602                            pause = Some(Arc::new(PauseRuntime::new(
603                                sessions.clone(),
604                                me.clone(),
605                                Arc::clone(&control),
606                                &feed,
607                                source,
608                                reconcile.clone(),
609                            )));
610                        }
611                        (Some(_), None) => {
612                            warn!(topic = %self.topic, "the source topic ids did not resolve, sessions of this agent cannot be paused");
613                        }
614                        (None, _) => {}
615                    }
616                    control.set_live(true);
617                    let (stop, stopped) = oneshot::channel();
618                    let task = tokio::spawn(crate::agent::pause::follow_control(
619                        laser.clone(),
620                        feed,
621                        me.clone(),
622                        Arc::clone(&control),
623                        pause.is_some().then(|| reconcile.clone()),
624                        self.poll_interval,
625                        stopped,
626                    ));
627                    Some((stop, task))
628                }
629                // No control topic yet. The follower starts once the worker
630                // exists, keeps looking for the topic, and then starts the
631                // pause runtime too, so a late topic holds delivery as well.
632                None => {
633                    late_control = true;
634                    None
635                }
636            }
637        };
638        drop(reconcile);
639
640        let probe_interval = sessions.config().heartbeat_value();
641        let reliable = Arc::new(ReliableWorker {
642            handler,
643            laser: laser.clone(),
644            retry: self.retry,
645            understood_features: self.understood_features,
646            dedup: deduplicator,
647            agent: self.agent,
648            respond_on: self.respond_on,
649            inbox_route: self.inbox_route,
650            ack_on_pickup: self.ack_on_pickup,
651            stream_id,
652            topic_id,
653            middleware: self.middleware,
654            on_dead_letter: self.on_dead_letter,
655            topic: self.topic.clone(),
656            operations: self.operations,
657            sessions,
658            control,
659            pause: Arc::new(
660                pause
661                    .clone()
662                    .map_or_else(std::sync::OnceLock::new, std::sync::OnceLock::from),
663            ),
664            budget: BudgetGate::default(),
665            high_water_fence: dashmap::DashMap::new(),
666            fence_last_sweep: std::sync::atomic::AtomicU64::new(0),
667            #[cfg(feature = "sign")]
668            verifier: self.verifier,
669            #[cfg(feature = "sign")]
670            signing_key: self.signing_key,
671            #[cfg(feature = "sign")]
672            verified_records: Mutex::new(DedupWindow::new(VERIFIED_RECORD_WINDOW)),
673        });
674
675        // Rebuild the pause state from the log before the first dispatch,
676        // then let the pause driver bring each session a request names up
677        // to date.
678        let pause_driver = match &pause {
679            Some(runtime) => {
680                if let Err(error) = runtime.recover().await {
681                    stop_follower(control_follower).await;
682                    return Err(error);
683                }
684                let (stop, stopped) = oneshot::channel();
685                let worker: Arc<dyn Replay> = reliable.clone();
686                let task = tokio::spawn(crate::agent::pause::drive(
687                    Arc::clone(runtime),
688                    worker,
689                    reconcile_requests,
690                    stopped,
691                ));
692                Some((stop, task))
693            }
694            None => None,
695        };
696
697        // A control topic missing at start: follow it once it appears and
698        // start the pause runtime then, recovered from the log first.
699        let late_driver: LateDriver = Arc::default();
700        if late_control {
701            let start_pause = match (&me, source) {
702                (Some(me), Some(source)) => {
703                    let slot = Arc::clone(&reliable.pause);
704                    let sessions = reliable.sessions.clone();
705                    let me = me.clone();
706                    let book = Arc::clone(&reliable.control);
707                    let worker: Arc<dyn Replay> = reliable.clone();
708                    let driver = Arc::clone(&late_driver);
709                    let start: crate::agent::pause::StartPause = Box::new(move |feed| {
710                        let (reconcile, requests) = tokio::sync::mpsc::unbounded_channel();
711                        let runtime = Arc::new(PauseRuntime::new(
712                            sessions,
713                            me,
714                            book,
715                            feed,
716                            source,
717                            reconcile.clone(),
718                        ));
719                        Box::pin(async move {
720                            start_late_pause(runtime, worker, requests, reconcile, slot, driver)
721                                .await
722                        })
723                    });
724                    Some(start)
725                }
726                _ => None,
727            };
728            let (stop, stopped) = oneshot::channel();
729            let task = tokio::spawn(crate::agent::pause::discover_then_follow_control(
730                laser.clone(),
731                me.clone(),
732                Arc::clone(&reliable.control),
733                start_pause,
734                self.poll_interval,
735                stopped,
736            ));
737            control_follower = Some((stop, task));
738        }
739
740        // An aborted or dropped runtime stops its background tasks with it.
741        let _late = AbortLateOnDrop(Arc::clone(&late_driver));
742        let _background = AbortOnDrop(
743            control_follower
744                .iter()
745                .map(|(_, task)| task.abort_handle())
746                .chain(pause_driver.iter().map(|(_, task)| task.abort_handle()))
747                .collect(),
748        );
749
750        // Joined, dedup-warmed, and recovered: signal readiness. A dropped
751        // receiver is fine.
752        let _ = ready.send(());
753        // A dropped shutdown sender never stops the consumer, only a sent
754        // signal does.
755        let stop = async {
756            if shutdown.await.is_err() {
757                std::future::pending::<()>().await;
758            }
759        }
760        .fuse();
761        let result = match self.concurrency {
762            ConcurrencyPolicy::Serial => {
763                run_serial(&opener, consumer, &reliable, stop, self.shutdown_grace).await
764            }
765            ConcurrencyPolicy::SerialPerPartition { max_partitions } => {
766                let limits = LaneLimits {
767                    max_partitions: max_partitions.max(1),
768                    max_queued_records: self.max_queued_records.clamp(1, u32::MAX as usize),
769                    max_queued_bytes: self.max_queued_bytes.clamp(1, u32::MAX as usize),
770                    probe_interval,
771                };
772                run_per_partition(
773                    &opener,
774                    consumer,
775                    Arc::clone(&reliable),
776                    limits,
777                    stop,
778                    self.shutdown_grace,
779                )
780                .await
781            }
782        };
783        if let Some((stop, mut task)) = pause_driver {
784            let _ = stop.send(());
785            if tokio::time::timeout(self.shutdown_grace, &mut task)
786                .await
787                .is_err()
788            {
789                warn!("the pause driver did not finish within the shutdown grace, aborting it");
790                task.abort();
791            }
792        }
793        let late = late_driver
794            .lock()
795            .unwrap_or_else(std::sync::PoisonError::into_inner)
796            .take();
797        if let Some((stop, mut task)) = late {
798            let _ = stop.send(());
799            if tokio::time::timeout(self.shutdown_grace, &mut task)
800                .await
801                .is_err()
802            {
803                task.abort();
804            }
805        }
806        stop_follower(control_follower).await;
807        result
808    }
809}
810
811/// The pause driver started after a late control topic appeared.
812type LateDriver = Arc<Mutex<Option<(oneshot::Sender<()>, tokio::task::JoinHandle<()>)>>>;
813
814// Aborts a late pause driver when the runtime future is dropped.
815struct AbortLateOnDrop(LateDriver);
816
817impl Drop for AbortLateOnDrop {
818    fn drop(&mut self) {
819        if let Some((_, task)) = self
820            .0
821            .lock()
822            .unwrap_or_else(std::sync::PoisonError::into_inner)
823            .take()
824        {
825            task.abort();
826        }
827    }
828}
829
830// Recover a pause runtime that started after a late control topic appeared,
831// install it, and start its driver. Recovery retries on the consumer backoff.
832// A runtime that never recovers is not installed, so delivery is not held.
833async fn start_late_pause(
834    runtime: Arc<PauseRuntime>,
835    worker: Arc<dyn Replay>,
836    requests: tokio::sync::mpsc::UnboundedReceiver<ConversationId>,
837    reconcile: tokio::sync::mpsc::UnboundedSender<ConversationId>,
838    slot: Arc<std::sync::OnceLock<Arc<PauseRuntime>>>,
839    driver: LateDriver,
840) -> Option<tokio::sync::mpsc::UnboundedSender<ConversationId>> {
841    let mut attempt = 0;
842    loop {
843        match runtime.recover().await {
844            Ok(()) => break,
845            Err(error) => {
846                attempt += 1;
847                if attempt >= crate::retry::CONSUMER_FAILURE_BUDGET {
848                    warn!(%error, "the pause runtime for the late control topic did not recover, sessions of this agent cannot be paused");
849                    return None;
850                }
851                tokio::time::sleep(crate::retry::CONSUMER.delay(attempt)).await;
852            }
853        }
854    }
855    if slot.set(Arc::clone(&runtime)).is_err() {
856        return None;
857    }
858    let (stop, stopped) = oneshot::channel();
859    let task = tokio::spawn(crate::agent::pause::drive(
860        runtime, worker, requests, stopped,
861    ));
862    *driver
863        .lock()
864        .unwrap_or_else(std::sync::PoisonError::into_inner) = Some((stop, task));
865    Some(reconcile)
866}
867
868// Aborts the runtime's background tasks when the runtime future is dropped,
869// so an aborted agent leaves no control follower or pause driver behind.
870struct AbortOnDrop(Vec<tokio::task::AbortHandle>);
871
872impl Drop for AbortOnDrop {
873    fn drop(&mut self) {
874        for task in &self.0 {
875            task.abort();
876        }
877    }
878}
879
880// Stop the control follower and wait for it to leave.
881async fn stop_follower(follower: Option<(oneshot::Sender<()>, tokio::task::JoinHandle<()>)>) {
882    if let Some((stop, task)) = follower {
883        let _ = stop.send(());
884        let _ = task.await;
885    }
886}
887
888/// How the runtime reads its groups, decided once at spawn from the
889/// server's capabilities.
890#[derive(Clone, Copy)]
891struct DeliveryEngine {
892    /// Read groups natively. Only when no group policy can apply.
893    native: bool,
894    /// The server serves filtered reads and the catalog that binds a group
895    /// to its filter.
896    filters: bool,
897}
898
899impl DeliveryEngine {
900    async fn resolve(laser: &Laser) -> Result<Self, LaserError> {
901        let mut capabilities = laser.capabilities().await;
902        if capabilities.hello == HelloOutcome::Unknown {
903            capabilities = laser.refresh_capabilities().await;
904        }
905        // An uncertain probe, or a server that serves filters without
906        // group-aware reads, is an error here, never a native fallback.
907        if !crate::stream::transport::policy_aware(&capabilities)? {
908            return Ok(Self {
909                native: true,
910                filters: false,
911            });
912        }
913        // The group-aware engine opens its own connections, which a client
914        // brought by the caller cannot. Such a runtime reads natively and
915        // binds no filter: delivery stays correct because the runtime still
916        // classifies every record by its addressee, it only examines more.
917        if laser.connection_string().is_none() {
918            if capabilities.managed {
919                tracing::warn!(
920                    "the agent reads natively because its client was not built from a connection string, so no group filter is bound"
921                );
922            }
923            return Ok(Self {
924                native: true,
925                filters: false,
926            });
927        }
928        Ok(Self {
929            native: false,
930            filters: capabilities.filters.native && capabilities.filters.catalog,
931        })
932    }
933
934    // Bind the role group of `topic` to the addressee filter. Only the
935    // session topics carry `agdx.to` on every record, so a filter on any
936    // other topic would drop untargeted records.
937    async fn bind_addressee(
938        &self,
939        laser: &Laser,
940        stream: &str,
941        topic: &str,
942        group: &str,
943        me: &laser_wire::agent::AgentId,
944    ) -> Result<(), LaserError> {
945        if !self.filters || (topic != AGENT_SESSIONS && topic != AGENT_CONTROL) {
946            return Ok(());
947        }
948        laser
949            .stream(stream)
950            .topic(topic)
951            .consumer_group(group)
952            .create()
953            .filter(addressee_filter(me))
954            .build()
955            .await
956            .map(|_| ())
957    }
958}
959
960/// Opens the runtime's consumer of one topic, again after a recoverable
961/// failure.
962#[derive(Clone)]
963struct Opener {
964    laser: Laser,
965    stream: String,
966    topic: String,
967    group: String,
968    poll_interval: Duration,
969    native: bool,
970}
971
972impl Opener {
973    async fn open(&self) -> Result<Consumer, LaserError> {
974        let builder = self
975            .laser
976            .stream(&self.stream)
977            .topic(&self.topic)
978            .consumer_group(&self.group)
979            .consumer()
980            .commit_policy(CommitPolicy::Disabled)
981            .poll_interval(self.poll_interval)
982            .create_group(true);
983        let builder = if self.native {
984            builder.native()
985        } else {
986            builder
987        };
988        builder.build().await
989    }
990
991    // Leave the failed consumer and open another, after the backoff the
992    // failure count asks for. A failed open counts as another consumer
993    // failure, so a brief outage during the reopen is retried within the same
994    // budget instead of ending the consumer.
995    async fn reopen(&self, consumer: &mut Consumer, failures: &mut u32) -> Result<(), LaserError> {
996        if let Err(error) = consumer.shutdown().await {
997            debug!(%error, topic = %self.topic, "the failed consumer did not leave its group cleanly");
998        }
999        loop {
1000            sleep(backoff_for(*failures)).await;
1001            match self.open().await {
1002                Ok(reopened) => {
1003                    *consumer = reopened;
1004                    return Ok(());
1005                }
1006                Err(error) if recoverable(&error, *failures + 1) => {
1007                    *failures += 1;
1008                    warn!(%error, attempt = *failures, topic = %self.topic, "reopening the agent consumer failed, retrying");
1009                }
1010                Err(error) => return Err(error),
1011            }
1012        }
1013    }
1014}
1015
1016// The consecutive consumer failures after which a retryable failure is no
1017// longer treated as transient.
1018pub(crate) const MAX_CONSECUTIVE_POLL_ERRORS: u32 = crate::retry::CONSUMER_FAILURE_BUDGET;
1019
1020// Whether a consumer failure is worth reopening the consumer for. The
1021// verdict is `LaserError::is_retryable`, the one TypeScript shares: a stale
1022// membership, an unavailable catalog, and transient transport failures are,
1023// a policy conflict, a changed source, and a filter fault are not. A member
1024// the group no longer knows rejoins.
1025fn recoverable(error: &LaserError, failures: u32) -> bool {
1026    let rejoin = matches!(
1027        error,
1028        LaserError::Iggy(IggyError::ConsumerGroupMemberNotFound(..))
1029    );
1030    (rejoin || error.is_retryable()) && failures < MAX_CONSECUTIVE_POLL_ERRORS
1031}
1032
1033// The serial scheduler: one record at a time, committed after it is handled,
1034// by the task that owns the consumer. A failed dead-letter publish stops the
1035// consumer with the record uncommitted.
1036async fn run_serial<H, S>(
1037    opener: &Opener,
1038    mut consumer: Consumer,
1039    worker: &ReliableWorker<H>,
1040    stop: S,
1041    shutdown_grace: Duration,
1042) -> Result<(), LaserError>
1043where
1044    H: AgentHandler + Sync + Send + 'static,
1045    S: std::future::Future<Output = ()> + futures::future::FusedFuture,
1046{
1047    tokio::pin!(stop);
1048    let mut failures = 0u32;
1049    let mut stopping = false;
1050    let result = loop {
1051        if stopping {
1052            break Ok(());
1053        }
1054        let next = tokio::select! {
1055            () = &mut stop => break Ok(()),
1056            next = consumer.next() => next,
1057        };
1058        let message = match next {
1059            None => break Ok(()),
1060            Some(Ok(message)) => message,
1061            Some(Err(error)) => {
1062                failures += 1;
1063                if !recoverable(&error, failures) {
1064                    break Err(error);
1065                }
1066                warn!(%error, attempt = failures, topic = %opener.topic, "agent consumer failed, reopening it");
1067                if let Err(error) = opener.reopen(&mut consumer, &mut failures).await {
1068                    break Err(error);
1069                }
1070                worker.invalidate_pause();
1071                continue;
1072            }
1073        };
1074        failures = 0;
1075        worker.observe_assignment(&consumer);
1076        let handled = worker.consume(&message);
1077        tokio::pin!(handled);
1078        let outcome = tokio::select! {
1079            outcome = &mut handled => outcome,
1080            () = &mut stop => {
1081                stopping = true;
1082                match tokio::time::timeout(shutdown_grace, &mut handled).await {
1083                    Ok(outcome) => outcome,
1084                    // The record is still in flight, so the consumer is not
1085                    // shut down: leaving would store nothing more, but the
1086                    // connection close ends the membership instead.
1087                    Err(_) => return Err(LaserError::Timeout("agent shutdown drain")),
1088                }
1089            }
1090        };
1091        if let Err(error) = outcome {
1092            break Err(error);
1093        }
1094        if let Err(error) = consumer.commit(&message).await {
1095            failures += 1;
1096            if !recoverable(&error, failures) {
1097                break Err(error);
1098            }
1099            warn!(%error, source = %message.position, "committing a handled record failed, reopening the consumer");
1100            if let Err(error) = opener.reopen(&mut consumer, &mut failures).await {
1101                break Err(error);
1102            }
1103            worker.invalidate_pause();
1104        }
1105    };
1106    if let Err(error) = consumer.shutdown().await {
1107        warn!(%error, "failed to leave the consumer group on shutdown");
1108    }
1109    result
1110}
1111
1112/// The per-partition scheduler's bounds.
1113struct LaneLimits {
1114    max_partitions: usize,
1115    max_queued_records: usize,
1116    max_queued_bytes: usize,
1117    /// How often the assignment probe drops lanes of partitions this member
1118    /// no longer reads.
1119    probe_interval: Duration,
1120}
1121
1122// The per-partition scheduler: route each record to a lane keyed by
1123// partition and commit it once its lane has handled it. Lanes run
1124// concurrently across partitions, and within one partition a lane is a
1125// serial queue, so ordering and the committed prefix hold. Commits are issued
1126// by this task, the sole owner of the consumer, from lane completions. A lane
1127// whose dead-letter publish fails stops, so its queued successors are never
1128// committed. A recoverable consumer failure drains every lane, then reopens
1129// the consumer.
1130async fn run_per_partition<H, S>(
1131    opener: &Opener,
1132    mut consumer: Consumer,
1133    worker: Arc<ReliableWorker<H>>,
1134    limits: LaneLimits,
1135    stop: S,
1136    shutdown_grace: Duration,
1137) -> Result<(), LaserError>
1138where
1139    H: AgentHandler + Sync + Send + 'static,
1140    S: std::future::Future<Output = ()> + futures::future::FusedFuture,
1141{
1142    tokio::pin!(stop);
1143    let mut failures = 0u32;
1144    loop {
1145        let (outcome, drain) =
1146            drive_lanes(opener, &mut consumer, &worker, &limits, &mut stop).await;
1147        if drain.delivered {
1148            failures = 0;
1149        }
1150        // After a consumer failure a commit may be refused for a partition
1151        // the member lost. Its records are read again by the new owner.
1152        let tolerate_commits = matches!(outcome, LaneOutcome::Failed(_));
1153        let drained =
1154            tokio::time::timeout(shutdown_grace, drain.finish(&consumer, tolerate_commits)).await;
1155        match outcome {
1156            LaneOutcome::Stopped => {
1157                return match drained {
1158                    Ok(Ok(())) => {
1159                        // Every lane finished and committed, so the member can
1160                        // leave its group. On a drain timeout the lanes may
1161                        // hold unhandled records, and the membership is left
1162                        // to the connection close instead.
1163                        if let Err(error) = consumer.shutdown().await {
1164                            warn!(%error, "failed to leave the consumer group on shutdown");
1165                        }
1166                        Ok(())
1167                    }
1168                    Ok(Err(error)) => Err(error),
1169                    Err(_) => Err(LaserError::Timeout("agent shutdown drain")),
1170                };
1171            }
1172            LaneOutcome::Fatal(error) => {
1173                // The failed lane committed nothing after its record, so
1174                // leaving stores no more than what was committed.
1175                if matches!(drained, Ok(Ok(())))
1176                    && let Err(error) = consumer.shutdown().await
1177                {
1178                    warn!(%error, "failed to leave the consumer group on shutdown");
1179                }
1180                return Err(error);
1181            }
1182            LaneOutcome::Failed(error) => {
1183                if let Ok(Err(drain_error)) | Err(drain_error) =
1184                    drained.map_err(|_| LaserError::Timeout("agent lane drain"))
1185                {
1186                    return Err(drain_error);
1187                }
1188                failures += 1;
1189                if !recoverable(&error, failures) {
1190                    return Err(error);
1191                }
1192                warn!(%error, attempt = failures, topic = %opener.topic, "agent consumer failed, reopening it");
1193                opener.reopen(&mut consumer, &mut failures).await?;
1194                worker.invalidate_pause();
1195            }
1196        }
1197    }
1198}
1199
1200enum LaneOutcome {
1201    /// Shutdown was signalled or the stream ended.
1202    Stopped,
1203    /// The consumer failed, which may be recoverable.
1204    Failed(LaserError),
1205    /// A lane could not dead-letter a record. Nothing after it is committed.
1206    Fatal(LaserError),
1207}
1208
1209struct Lane {
1210    sender: tokio::sync::mpsc::Sender<QueuedMessage>,
1211    task: tokio::task::JoinHandle<()>,
1212}
1213
1214// The lanes still running after the polling loop ends, and their pending
1215// completions.
1216struct LaneDrain {
1217    lanes: Vec<tokio::task::JoinHandle<()>>,
1218    completions: tokio::sync::mpsc::Receiver<Result<ConsumerMessage, LaserError>>,
1219    /// Whether the pass delivered any record, which resets the failure count.
1220    delivered: bool,
1221}
1222
1223impl LaneDrain {
1224    // Wait for every lane to finish its queue and commit what it handled.
1225    // The first lane failure stops further commits.
1226    async fn finish(
1227        mut self,
1228        consumer: &Consumer,
1229        tolerate_commits: bool,
1230    ) -> Result<(), LaserError> {
1231        for lane in self.lanes {
1232            let _ = lane.await;
1233        }
1234        while let Some(completion) = self.completions.recv().await {
1235            let message = completion?;
1236            match consumer.commit(&message).await {
1237                Ok(()) => {}
1238                Err(error) if tolerate_commits => {
1239                    debug!(%error, source = %message.position, "a handled record could not be committed, it is read again");
1240                }
1241                Err(error) => return Err(error),
1242            }
1243        }
1244        Ok(())
1245    }
1246}
1247
1248async fn drive_lanes<H, S>(
1249    opener: &Opener,
1250    consumer: &mut Consumer,
1251    worker: &Arc<ReliableWorker<H>>,
1252    limits: &LaneLimits,
1253    stop: &mut std::pin::Pin<&mut S>,
1254) -> (LaneOutcome, LaneDrain)
1255where
1256    H: AgentHandler + Sync + Send + 'static,
1257    S: std::future::Future<Output = ()> + futures::future::FusedFuture,
1258{
1259    let mut lanes: std::collections::HashMap<u32, Lane> = std::collections::HashMap::new();
1260    // Lanes of revoked partitions finishing their queues. A lane opened for
1261    // the same partition waits for its predecessor, so a partition is never
1262    // handled by two lanes at once.
1263    let mut retiring: std::collections::HashMap<u32, tokio::task::JoinHandle<()>> =
1264        std::collections::HashMap::new();
1265    let (commit_tx, mut commit_rx) = tokio::sync::mpsc::channel::<
1266        Result<ConsumerMessage, LaserError>,
1267    >(limits.max_partitions * 2);
1268    let notify = Arc::new(tokio::sync::Notify::new());
1269    let record_capacity = Arc::new(tokio::sync::Semaphore::new(limits.max_queued_records));
1270    let byte_capacity = Arc::new(tokio::sync::Semaphore::new(limits.max_queued_bytes));
1271    let mut probe = tokio::time::interval(limits.probe_interval);
1272    probe.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1273    probe.reset();
1274    let mut delivered = false;
1275
1276    let outcome = 'polling: loop {
1277        // Commit every completed record before polling again. The group
1278        // engine applies these between reads, so no read is cancelled.
1279        while let Ok(completion) = commit_rx.try_recv() {
1280            match completion {
1281                Ok(message) => {
1282                    if let Err(error) = consumer.commit_handled(&message).await {
1283                        break 'polling LaneOutcome::Failed(error);
1284                    }
1285                }
1286                Err(error) => break 'polling LaneOutcome::Fatal(error),
1287            }
1288        }
1289        tokio::select! {
1290            () = &mut *stop => break LaneOutcome::Stopped,
1291            // Wake to commit a completion at the top of the loop. The value
1292            // stays in the channel, so a spurious wake is harmless.
1293            () = notify.notified() => {}
1294            _ = probe.tick() => {
1295                drop_revoked(opener, consumer, &mut lanes, &mut retiring).await;
1296            }
1297            next = consumer.next() => match next {
1298                Some(Ok(message)) => {
1299                    delivered = true;
1300                    worker.observe_assignment(consumer);
1301                    let partition = message.partition_id;
1302                    if !lanes.contains_key(&partition) && lanes.len() >= limits.max_partitions {
1303                        // Over the lane cap: handle inline rather than drop
1304                        // the record or spawn an unbounded number of lanes.
1305                        // No lane holds this partition, so nothing queued
1306                        // precedes the record.
1307                        if let Err(error) = worker.consume(&message).await {
1308                            break 'polling LaneOutcome::Fatal(error);
1309                        }
1310                        if let Err(error) = consumer.commit_handled(&message).await {
1311                            break 'polling LaneOutcome::Failed(error);
1312                        }
1313                        continue;
1314                    }
1315                    let lane = lanes.entry(partition).or_insert_with(|| {
1316                        spawn_lane(
1317                            worker,
1318                            retiring.remove(&partition),
1319                            limits.max_queued_records,
1320                            commit_tx.clone(),
1321                            Arc::clone(&notify),
1322                        )
1323                    });
1324                    let buffered_bytes = message.payload.len()
1325                        + message.user_headers.as_ref().map_or(0, bytes::Bytes::len);
1326                    let Ok(record_permit) = Arc::clone(&record_capacity).acquire_owned().await
1327                    else {
1328                        break 'polling LaneOutcome::Fatal(LaserError::HandlerConfig(
1329                            "record queue closed".to_owned(),
1330                        ));
1331                    };
1332                    let byte_permits = buffered_bytes.clamp(1, limits.max_queued_bytes) as u32;
1333                    let Ok(byte_permit) = Arc::clone(&byte_capacity)
1334                        .acquire_many_owned(byte_permits)
1335                        .await
1336                    else {
1337                        break 'polling LaneOutcome::Fatal(LaserError::HandlerConfig(
1338                            "byte queue closed".to_owned(),
1339                        ));
1340                    };
1341                    let queued = QueuedMessage {
1342                        message,
1343                        record_permit,
1344                        byte_permit,
1345                    };
1346                    if lane.sender.send(queued).await.is_err() {
1347                        // The lane stopped after a failed dead-letter publish.
1348                        // Its failure is waiting in the completion channel.
1349                        continue;
1350                    }
1351                }
1352                Some(Err(error)) => {
1353                    if matches!(error, LaserError::Iggy(IggyError::ConsumerGroupMemberNotFound(..))) {
1354                        drop_revoked(opener, consumer, &mut lanes, &mut retiring).await;
1355                    }
1356                    break LaneOutcome::Failed(error);
1357                }
1358                None => break LaneOutcome::Stopped,
1359            },
1360        }
1361    };
1362    // Dropping the senders lets each lane finish its queue and exit.
1363    let mut tasks: Vec<_> = lanes.into_values().map(|lane| lane.task).collect();
1364    tasks.extend(retiring.into_values());
1365    drop(commit_tx);
1366    (
1367        outcome,
1368        LaneDrain {
1369            lanes: tasks,
1370            completions: commit_rx,
1371            delivered,
1372        },
1373    )
1374}
1375
1376fn spawn_lane<H>(
1377    worker: &Arc<ReliableWorker<H>>,
1378    predecessor: Option<tokio::task::JoinHandle<()>>,
1379    capacity: usize,
1380    commits: tokio::sync::mpsc::Sender<Result<ConsumerMessage, LaserError>>,
1381    notify: Arc<tokio::sync::Notify>,
1382) -> Lane
1383where
1384    H: AgentHandler + Sync + Send + 'static,
1385{
1386    let (sender, mut queue) = tokio::sync::mpsc::channel::<QueuedMessage>(capacity);
1387    let worker = Arc::clone(worker);
1388    let task = tokio::spawn(async move {
1389        if let Some(predecessor) = predecessor {
1390            let _ = predecessor.await;
1391        }
1392        while let Some(queued) = queue.recv().await {
1393            let result = worker.consume(&queued.message).await;
1394            drop(queued.record_permit);
1395            drop(queued.byte_permit);
1396            let failed = result.is_err();
1397            let _ = commits.send(result.map(|()| queued.message)).await;
1398            notify.notify_one();
1399            if failed {
1400                break;
1401            }
1402        }
1403    });
1404    Lane { sender, task }
1405}
1406
1407// The assignment probe. A lane of a partition this member no longer reads is
1408// closed: it finishes the records it holds and exits, instead of living until
1409// shutdown. The group-aware engine knows its own assignment. A native group
1410// asks the server.
1411async fn drop_revoked(
1412    opener: &Opener,
1413    consumer: &Consumer,
1414    lanes: &mut std::collections::HashMap<u32, Lane>,
1415    retiring: &mut std::collections::HashMap<u32, tokio::task::JoinHandle<()>>,
1416) {
1417    let assigned = match consumer.assigned_partitions() {
1418        Some(assigned) => Some(assigned),
1419        None => opener.assigned_partitions().await,
1420    };
1421    let Some(assigned) = assigned else {
1422        return;
1423    };
1424    let revoked: Vec<u32> = lanes
1425        .keys()
1426        .filter(|partition| !assigned.contains(partition))
1427        .copied()
1428        .collect();
1429    for partition in revoked {
1430        if let Some(lane) = lanes.remove(&partition) {
1431            debug!(
1432                partition,
1433                "dropping the lane of a partition no longer assigned"
1434            );
1435            drop(lane.sender);
1436            retiring.insert(partition, lane.task);
1437        }
1438    }
1439    retiring.retain(|_, task| !task.is_finished());
1440}
1441
1442impl Opener {
1443    // The partitions the server assigns this connection in the group, `None`
1444    // when the probe fails or the connection is not a member.
1445    async fn assigned_partitions(&self) -> Option<std::collections::BTreeSet<u32>> {
1446        let client = self.laser.client();
1447        let me = match client.get_me().await {
1448            Ok(me) => me.client_id,
1449            Err(error) => {
1450                debug!(%error, "the assignment probe could not identify this client");
1451                return None;
1452            }
1453        };
1454        let group = client
1455            .get_consumer_group(
1456                &Identifier::named(&self.stream).ok()?,
1457                &Identifier::named(&self.topic).ok()?,
1458                &Identifier::named(&self.group).ok()?,
1459            )
1460            .await;
1461        match group {
1462            Ok(Some(details)) => details
1463                .members
1464                .iter()
1465                .find(|member| member.id == me)
1466                .map(|member| member.partitions.iter().copied().collect()),
1467            Ok(None) => None,
1468            Err(error) => {
1469                debug!(%error, "the assignment probe could not read the group");
1470                None
1471            }
1472        }
1473    }
1474}
1475
1476struct QueuedMessage {
1477    message: ConsumerMessage,
1478    record_permit: tokio::sync::OwnedSemaphorePermit,
1479    byte_permit: tokio::sync::OwnedSemaphorePermit,
1480}
1481
1482/// The dedup key, principal-scoped so one producer cannot suppress or replay
1483/// another's idempotency key. Composed as `{agent}{SEP}{key}`. The agent is
1484/// publisher-asserted, so this is a namespace against accidental reuse, not a
1485/// security boundary (the fence is the real at-most-once gate). The live and
1486/// warm-up paths both go through this, or dedup breaks after a restart.
1487fn dedup_key(provenance: &Provenance) -> Option<String> {
1488    let key = provenance.idempotency_key.as_ref()?;
1489    Some(match &provenance.agent {
1490        Some(agent) => format!("{}{DEDUP_SCOPE_SEP}{key}", agent.as_str()),
1491        None => key.clone(),
1492    })
1493}
1494
1495/// One task's fence high-water mark and when it was last advanced, so an idle
1496/// entry can be swept without losing the gate for an active task.
1497#[derive(Clone, Copy)]
1498struct FenceEntry {
1499    fence: u64,
1500    touched_micros: u64,
1501}
1502
1503/// The monotonic high-water-mark fence gate. Returns `true` to accept the fence
1504/// (advancing the task's high water) or `false` to drop a stale-holder replay
1505/// whose fence is below the highest already accepted. An equal fence is accepted,
1506/// the same holder's legitimate retry, which dedup then handles. When the map is
1507/// over its soft cap, an idle-entry sweep runs at most once per sweep interval,
1508/// bounding memory without reopening the gate for any recently-active task.
1509fn accept_fence(
1510    high_water: &dashmap::DashMap<ConversationId, FenceEntry>,
1511    last_sweep_micros: &std::sync::atomic::AtomicU64,
1512    task: ConversationId,
1513    fence: u64,
1514    now_micros: u64,
1515) -> bool {
1516    if high_water.len() > FENCE_MAP_SOFT_CAP {
1517        use std::sync::atomic::Ordering;
1518        let previous = last_sweep_micros.load(Ordering::Relaxed);
1519        if now_micros.saturating_sub(previous) > FENCE_SWEEP_INTERVAL_MICROS
1520            && last_sweep_micros
1521                .compare_exchange(previous, now_micros, Ordering::Relaxed, Ordering::Relaxed)
1522                .is_ok()
1523        {
1524            high_water.retain(|_, entry| {
1525                now_micros.saturating_sub(entry.touched_micros) < FENCE_ENTRY_TTL_MICROS
1526            });
1527        }
1528    }
1529    let mut entry = high_water.entry(task).or_insert(FenceEntry {
1530        fence: 0,
1531        touched_micros: now_micros,
1532    });
1533    if fence < entry.fence {
1534        return false;
1535    }
1536    entry.fence = fence;
1537    entry.touched_micros = now_micros;
1538    true
1539}
1540
1541// The identity an agentless consumer classifies untargeted records as.
1542const ANONYMOUS_AGENT: &str = "anonymous";
1543
1544struct ReliableWorker<H> {
1545    handler: H,
1546    laser: Laser,
1547    retry: RetryPolicy,
1548    understood_features: u64,
1549    dedup: Box<dyn Deduplicator>,
1550    agent: Option<AgentId>,
1551    respond_on: Option<AgentTopic<'static>>,
1552    inbox_route: crate::agent::router::InboxRoute,
1553    ack_on_pickup: bool,
1554    stream_id: u32,
1555    topic_id: u32,
1556    middleware: Vec<std::sync::Arc<dyn AgentMiddleware>>,
1557    on_dead_letter: Option<std::sync::Arc<dyn DeadLetterSink>>,
1558    topic: String,
1559    operations: Option<Vec<String>>,
1560    /// The session factory handlers and pickups open their lens from, under
1561    /// the consumer's session configuration.
1562    sessions: Sessions,
1563    /// The control requests the control subscription observed.
1564    control: Arc<ControlBook>,
1565    /// The pause runtime, for an agent with an id on a stream with
1566    /// `agent.control`. Set at start, or once a missing control topic appears.
1567    pause: Arc<std::sync::OnceLock<Arc<PauseRuntime>>>,
1568    /// The budget check before a session's work reaches the handler.
1569    budget: BudgetGate,
1570    #[cfg(feature = "sign")]
1571    verifier: Option<std::sync::Arc<crate::sign::KeyRegistry>>,
1572    #[cfg(feature = "sign")]
1573    signing_key: Option<std::sync::Arc<crate::sign::SigningKey>>,
1574    /// Highest fence token accepted per task (the conversation is the task scope).
1575    /// A log-resident effect with a lower fence is a stale-holder replay and is
1576    /// dropped before dedup, so it never consumes the legitimate retry's slot.
1577    /// Idle entries are swept past a ttl once over a soft cap, so the map stays
1578    /// bounded by the active working set.
1579    high_water_fence: dashmap::DashMap<ConversationId, FenceEntry>,
1580    /// When the fence map was last swept of idle entries (epoch micros), so the
1581    /// sweep runs at most once per interval under load.
1582    fence_last_sweep: std::sync::atomic::AtomicU64,
1583    /// Record ids already accepted through signature verification. The signed
1584    /// preimage covers the envelope but binds it to no log position, so the
1585    /// exact signed bytes stay valid wherever they are replayed. This bounded
1586    /// window refuses the second delivery of a record id, the same guard the
1587    /// registry fold keeps over applied facts.
1588    #[cfg(feature = "sign")]
1589    verified_records: Mutex<DedupWindow>,
1590}
1591
1592impl<H> ReliableWorker<H> {
1593    // Tell the pause runtime which partitions the consumer reads, when it
1594    // knows.
1595    fn observe_assignment(&self, consumer: &Consumer) {
1596        if let Some(pause) = self.pause.get() {
1597            pause.observe_assignment(consumer.assigned_partitions());
1598        }
1599    }
1600
1601    // The consumer was reopened: the pause runtime reads the lane again
1602    // before the next dispatch.
1603    fn invalidate_pause(&self) {
1604        if let Some(pause) = self.pause.get() {
1605            pause.invalidate();
1606        }
1607    }
1608
1609    fn dispatch(&self, message: &AgentMessage) -> Dispatch {
1610        // A consumer without an agent id accepts records for any addressee, so
1611        // it classifies each record as its own addressee would.
1612        let me = match (&self.agent, &message.provenance.target_agent_id) {
1613            (Some(agent), _) | (None, Some(agent)) => agent.wire_id(),
1614            (None, None) => ANONYMOUS_AGENT.parse().expect("a valid agent id"),
1615        };
1616        match &message.envelope {
1617            Some(envelope) => {
1618                let operations: Vec<&str> = self
1619                    .operations
1620                    .iter()
1621                    .flatten()
1622                    .map(String::as_str)
1623                    .collect();
1624                let handled = match self.operations {
1625                    Some(_) => HandledOperations::Only(&operations),
1626                    None => HandledOperations::Any,
1627                };
1628                classify(envelope, &self.topic, &me, handled)
1629            }
1630            None => classify_generic(
1631                message
1632                    .provenance
1633                    .target_agent_id
1634                    .as_ref()
1635                    .map(AgentId::as_str),
1636                message.provenance.causal_parent.is_some(),
1637                message.provenance.correlation_id.is_some(),
1638                &self.topic,
1639                &me,
1640            ),
1641        }
1642    }
1643
1644    async fn pick_up_submitted(
1645        &self,
1646        message: &AgentMessage,
1647    ) -> Option<crate::agent::SessionLease> {
1648        let envelope = message.envelope.as_ref()?;
1649        let submitted = envelope
1650            .metadata
1651            .as_ref()
1652            .and_then(|metadata| metadata.get(laser_wire::agent::METADATA_SUBMITTED))
1653            .is_some_and(|value| matches!(value, laser_wire::query::Value::Bool(true)));
1654        let agent = self.agent.as_ref()?;
1655        if !submitted || envelope.kind != AgentKind::Command {
1656            return None;
1657        }
1658        let session = self
1659            .sessions
1660            .open(message.provenance.conversation_id)
1661            .as_agent(agent.wire_id());
1662        match session.pick_up().await {
1663            Ok(lease) => Some(lease),
1664            Err(error) => {
1665                warn!(%error, source = %message.id, "failed to mark the submitted session working");
1666                None
1667            }
1668        }
1669    }
1670
1671    fn log_position(&self, id: MessageId) -> LogPosition {
1672        LogPosition::new(self.stream_id, self.topic_id, id.partition_id, id.offset)
1673    }
1674
1675    // Dead-letters a decoded message: the capsule carries the poison message's
1676    // log position, the reason code, the attempt count, a human detail, and the
1677    // original payload VERBATIM, so redrive is republishing those bytes.
1678    async fn dead_letter(
1679        &self,
1680        message: &AgentMessage,
1681        reason: DeadLetterReason,
1682        attempts: u32,
1683        detail: &str,
1684    ) -> Result<(), LaserError> {
1685        let capsule = AgentDeadLetter {
1686            source: self.log_position(message.id),
1687            reason,
1688            attempts,
1689            detail: Some(detail.to_owned()),
1690            payload: message.payload.clone(),
1691        };
1692        // Carry the original provenance for inspection, repointed at the poison
1693        // message. Clear the deadline so a deadline-bound DLQ consumer does not
1694        // re-drop the capsule for the very deadline that killed the original.
1695        let mut provenance = message.provenance.clone();
1696        provenance.causal_parent = Some(message.id);
1697        provenance.deadline = None;
1698        let session = Some(message.provenance.conversation_id);
1699        self.publish_dead_letter(provenance, message.id, capsule, Some(message), session)
1700            .await
1701    }
1702
1703    // Dead-letters a message whose provenance could not be decoded. The original
1704    // payload rides verbatim so nothing is lost. The capsule keeps the
1705    // record's own conversation when its header still reads, so the dead
1706    // letter stays on its session's timeline. A record without one gets a
1707    // conversation derived from its log position, stable across redeliveries.
1708    async fn dead_letter_undecodable(
1709        &self,
1710        source: MessageId,
1711        conversation: Option<ConversationId>,
1712        payload: Vec<u8>,
1713    ) -> Result<(), LaserError> {
1714        let capsule = AgentDeadLetter {
1715            source: self.log_position(source),
1716            reason: DeadLetterReason::DecodeFailed,
1717            attempts: 0,
1718            detail: None,
1719            payload,
1720        };
1721        let position = capsule.source;
1722        let provenance = Provenance::builder()
1723            .conversation_id(conversation.unwrap_or_else(|| {
1724                ConversationId::derive(&format!(
1725                    "dead-letter\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}",
1726                    position.stream_id, position.topic_id, position.partition_id, position.offset
1727                ))
1728            }))
1729            .causal_parent(source)
1730            .build();
1731        self.publish_dead_letter(provenance, source, capsule, None, conversation)
1732            .await
1733    }
1734
1735    async fn publish_dead_letter(
1736        &self,
1737        provenance: Provenance,
1738        source: MessageId,
1739        capsule: AgentDeadLetter,
1740        message: Option<&AgentMessage>,
1741        session: Option<ConversationId>,
1742    ) -> Result<(), LaserError> {
1743        let reason = capsule.reason;
1744        let result = self.send_dead_letter(&provenance, &capsule).await;
1745        if let Err(error) = &result {
1746            error!(%error, source = %source, ?reason, "failed to publish the dead-letter capsule, leaving the source offset uncommitted");
1747        }
1748        if let Some(sink) = &self.on_dead_letter {
1749            sink.on_dead_letter(message, &capsule, &result).await;
1750        }
1751        if result.is_ok()
1752            && let Some(session) = session
1753        {
1754            self.fail_session_on_dead_letter(session, &capsule).await;
1755        }
1756        result
1757    }
1758
1759    // Under `SessionConfig::fail_on_dead_letter`, a dead-lettered record
1760    // fails its session. Best effort: the dead letter is already published,
1761    // so a failed status write is logged and the record still commits.
1762    async fn fail_session_on_dead_letter(
1763        &self,
1764        session: ConversationId,
1765        capsule: &AgentDeadLetter,
1766    ) {
1767        if !self.sessions.config().fails_on_dead_letter() {
1768            return;
1769        }
1770        let Some(agent) = &self.agent else {
1771            warn!(%session, "a consumer without an agent id cannot fail the session of a dead letter");
1772            return;
1773        };
1774        let source = capsule.source;
1775        let body = AgentErrorBody {
1776            code: AgentErrorCode::Internal,
1777            message: Some(match capsule.detail.as_deref() {
1778                Some(detail) => format!(
1779                    "a record of this session was dead-lettered ({:?}): {detail}",
1780                    capsule.reason
1781                ),
1782                None => format!(
1783                    "a record of this session was dead-lettered ({:?})",
1784                    capsule.reason
1785                ),
1786            }),
1787            retryable: false,
1788            detail: Some(BTreeMap::from([
1789                (
1790                    "dead_letter_reason".to_owned(),
1791                    laser_wire::query::Value::Uint(u64::from(capsule.reason.code())),
1792                ),
1793                (
1794                    "source".to_owned(),
1795                    laser_wire::query::Value::Str(format!(
1796                        "{}/{}/{}/{}",
1797                        source.stream_id, source.topic_id, source.partition_id, source.offset
1798                    )),
1799                ),
1800            ])),
1801        };
1802        if let Err(error) = self
1803            .sessions
1804            .open(session)
1805            .as_agent(agent.wire_id())
1806            .fail(body)
1807            .await
1808        {
1809            warn!(%error, %session, "failed to fail the session of a dead-lettered record");
1810        }
1811    }
1812
1813    // Encode and publish one dead-letter capsule, returning the outcome so the
1814    // caller can log it and notify the sink. Any encode or header failure is a
1815    // lost message just like a publish failure, so it surfaces as an `Err`.
1816    async fn send_dead_letter(
1817        &self,
1818        provenance: &Provenance,
1819        capsule: &AgentDeadLetter,
1820    ) -> Result<(), LaserError> {
1821        let payload = encode_named(capsule)
1822            .map_err(|error| LaserError::Codec(format!("dead-letter capsule: {error}")))?;
1823        let mut headers = BTreeMap::<HeaderKey, HeaderValue>::try_from(provenance)
1824            .map_err(|error| LaserError::Codec(format!("dead-letter headers: {error}")))?;
1825        // Mark the capsule body as cbor so a DLQ consumer is self-describing.
1826        let key = HeaderKey::from_str(CONTENT_TYPE)?;
1827        headers.insert(key, HeaderValue::from(ContentType::Cbor.code()));
1828        let topic = AgentTopic::Dlq.topic_string();
1829        let partition_key = provenance.partition_key();
1830        self.laser
1831            .send_with_headers(&topic, payload, headers, Some(&partition_key))
1832            .await
1833            .map(|_| ())
1834    }
1835}
1836
1837impl<H> ReliableWorker<H>
1838where
1839    H: AgentHandler + Sync,
1840{
1841    // Handle one delivered record. `Err` only when a dead letter, a parking
1842    // record, or a pause acknowledgment could not be published, so the
1843    // record must stay uncommitted.
1844    #[tracing::instrument(target = "laser", level = "debug", skip_all, fields(conversation = tracing::field::Empty, operation = "handle"))]
1845    async fn consume(&self, received: &ConsumerMessage) -> Result<(), LaserError> {
1846        self.deliver(received, true).await
1847    }
1848
1849    // Handle one record. A live record passes the pause check, a held record
1850    // replayed after the resume does not.
1851    async fn deliver(&self, received: &ConsumerMessage, live: bool) -> Result<(), LaserError> {
1852        let source = received.position;
1853        let decoded = match AgentMessage::from_consumer(received, self.understood_features) {
1854            Ok(decoded) => decoded,
1855            Err((error, payload)) => {
1856                warn!(%error, source = %source, "undecodable provenance, dead-lettering raw payload");
1857                let conversation = (!received.headers_malformed)
1858                    .then(|| original_conversation(&received.headers))
1859                    .flatten();
1860                self.dead_letter_undecodable(source, conversation, payload)
1861                    .await?;
1862                return Ok(());
1863            }
1864        };
1865        #[cfg(feature = "sign")]
1866        let DecodedAgentMessage {
1867            mut message,
1868            signature_context,
1869            observed_at_micros,
1870        } = decoded;
1871        #[cfg(not(feature = "sign"))]
1872        let DecodedAgentMessage { message, .. } = decoded;
1873        tracing::Span::current().record(
1874            "conversation",
1875            tracing::field::display(&message.provenance.conversation_id),
1876        );
1877
1878        // Target-agent routing filter (defensive). Iggy's consumer-group
1879        // semantics already guarantee one delivery per group, so the
1880        // canonical one-agent-one-group setup (see `Agent` docstring) makes
1881        // this check a no-op in steady state. Bites in two cases:
1882        //   1. a publisher mis-addresses `target_agent_id` to the wrong
1883        //      agent that happens to subscribe to the same topic, drop
1884        //      cleanly instead of corrupting state with a misrouted handler
1885        //      invocation.
1886        //   2. operator error: two distinct agent ids accidentally joined
1887        //      the same consumer group, in which case Iggy delivers each
1888        //      message to ONE member and we want the other member's
1889        //      messages skipped, not handled.
1890        // Tolerating one-message-loss in case (2) is by design: the operator
1891        // is supposed to fix the group-per-agent setup, not have the SDK
1892        // paper over it by handling unrelated agents' work.
1893        if let (Some(target), Some(agent)) = (&message.provenance.target_agent_id, &self.agent)
1894            && target != agent
1895        {
1896            debug!(target = %target, agent = %agent, source = %message.id, "skipping message targeted at another agent");
1897            return Ok(());
1898        }
1899
1900        // Dispatch classification, shared with every SDK and the session fold.
1901        // Only work for an operation this handler serves reaches it. Replies,
1902        // status, events, control, and records for another agent are skipped
1903        // and still committed. The author is never a discriminator.
1904        let dispatch = self.dispatch(&message);
1905        if dispatch != Dispatch::Work {
1906            debug!(source = %message.id, dispatch = dispatch.as_str(), "skipping a record that is not work for this agent");
1907            return Ok(());
1908        }
1909
1910        // Mandatory signature verification on a verified (control or effect) topic.
1911        // An unsigned or unverified record is dead-lettered before any gate or
1912        // handler runs: the field is optional on the wire, so the only enforcement
1913        // is the consumer refusing to act on an unverified record here.
1914        #[cfg(feature = "sign")]
1915        if let Some(registry) = &self.verifier {
1916            let verified = message.envelope.as_ref().and_then(|envelope| {
1917                signature_context.as_ref().and_then(|context| {
1918                    registry
1919                        .verify_observed_at(envelope, context, observed_at_micros)
1920                        .ok()
1921                })
1922            });
1923            let Some(verified) = verified else {
1924                warn!(source = %message.id, "unsigned or unverified message on a verified topic, dead-lettering");
1925                self.dead_letter(
1926                    &message,
1927                    DeadLetterReason::Rejected,
1928                    0,
1929                    "signature verification failed",
1930                )
1931                .await?;
1932                return Ok(());
1933            };
1934            // A verified signature proves who authored the envelope, not that
1935            // this is its first delivery. Any writer on the topic can replay the
1936            // captured bytes, and they verify again, so a repeat record id is
1937            // dropped here before the handler acts on it.
1938            if let Some(record) = message
1939                .envelope
1940                .as_ref()
1941                .and_then(|envelope| envelope.record)
1942            {
1943                let first_delivery = self
1944                    .verified_records
1945                    .lock()
1946                    .unwrap_or_else(|poisoned| poisoned.into_inner())
1947                    .insert(&record.to_string());
1948                if !first_delivery {
1949                    warn!(source = %message.id, %record, "replayed signed record, dropping");
1950                    return Ok(());
1951                }
1952            }
1953            message.verified_principal = Some(verified.principal);
1954        }
1955
1956        // The pause check (D7a), before the fence and dedup gates so a held
1957        // record keeps its slot for the resume. Work for a paused session is
1958        // parked on the session lane and committed. A resumed session handles
1959        // its held records first. The session gate stays held through the
1960        // handler, so a pause acknowledgment follows the record in flight.
1961        let _gate = match (self.pause.get(), live) {
1962            (Some(pause), true) => {
1963                let gate = pause.gate(message.provenance.conversation_id).await;
1964                if pause.hold(self, &message).await? == Hold::Commit {
1965                    return Ok(());
1966                }
1967                Some(gate)
1968            }
1969            _ => None,
1970        };
1971
1972        // The budget check (D9), on a deployment that indexes sessions: work
1973        // for a session over its budget ends the session failed with reason
1974        // `budget`, once, and is committed without reaching the handler.
1975        if let Some(agent) = &self.agent {
1976            let session = message.provenance.conversation_id;
1977            let admitted = self
1978                .budget
1979                .admit(
1980                    &self.sessions,
1981                    session,
1982                    received.partition_id,
1983                    received.current_offset,
1984                    || {
1985                        let mut lens = self.sessions.open(session).as_agent(agent.wire_id());
1986                        lens.control = Some(Arc::clone(&self.control));
1987                        lens
1988                    },
1989                )
1990                .await;
1991            if !admitted {
1992                debug!(source = %message.id, "skipping work for a session over its budget");
1993                return Ok(());
1994            }
1995        }
1996
1997        // Fence gate, ordered BEFORE dedup. A log-resident effect carrying a fence
1998        // below the highest this task has accepted is a stale-holder replay: drop
1999        // it (the offset still commits, it is not a dead-letter). Running this
2000        // before `dedup.observe` matters, a fenced-out record must not consume the
2001        // idempotency slot the legitimate holder's retry needs. A malformed fence
2002        // already failed to decode (an error, never `.ok()`-ed to absent), so a
2003        // present token here is trustworthy.
2004        if let Some(fence) = message.provenance.fence_token
2005            && !accept_fence(
2006                &self.high_water_fence,
2007                &self.fence_last_sweep,
2008                message.provenance.conversation_id,
2009                fence,
2010                SystemClock.now_micros(),
2011            )
2012        {
2013            debug!(source = %message.id, fence, "stale-holder fence, dropping replay");
2014            return Ok(());
2015        }
2016
2017        if let Some(key) = dedup_key(&message.provenance) {
2018            // Dedup marks the key seen before the handler runs: a duplicate arriving
2019            // while the original is still in the window is skipped even if the
2020            // original later dead-letters. This is the at-least-once + idempotent
2021            // model, and a durable `Deduplicator` is the drop-in upgrade. The key is
2022            // principal-scoped (see `dedup_key`).
2023            if !self.dedup.observe(&key).await {
2024                debug!(dedup_key = %key, source = %message.id, "skipping duplicate message");
2025                return Ok(());
2026            }
2027        }
2028
2029        if let Some(deadline) = message.provenance.deadline
2030            && IggyTimestamp::now().as_micros() > deadline.as_micros()
2031        {
2032            warn!(source = %message.id, "message past its deadline, dead-lettering");
2033            self.dead_letter(
2034                &message,
2035                DeadLetterReason::DeadlineExceeded,
2036                0,
2037                "message past its deadline",
2038            )
2039            .await?;
2040            return Ok(());
2041        }
2042
2043        // Ack-on-pickup: a `Working` status the instant an AGDX command is taken,
2044        // before the handler runs, so a contract caller distinguishes consumed
2045        // from expired-unconsumed. The command survived the deadline check above,
2046        // so it was consumed in time.
2047        if self.ack_on_pickup
2048            && let (Some(agent), Some(respond_on), Some(envelope)) =
2049                (&self.agent, &self.respond_on, &message.envelope)
2050            && envelope.kind == AgentKind::Command
2051            && let Some(correlation) = envelope.correlation
2052        {
2053            let producer =
2054                self.laser
2055                    .agdx(respond_on.clone(), agent.wire_id(), envelope.conversation);
2056            let ack = producer
2057                .status(OPERATION_TASK)
2058                .with_correlation(correlation)
2059                .with_task_state(TaskState::Working);
2060            #[cfg(feature = "sign")]
2061            let ack = match &self.signing_key {
2062                Some(signing_key) => ack.signed_by(signing_key),
2063                None => ack,
2064            };
2065            if let Err(error) = ack.send().await {
2066                warn!(source = %message.id, %error, "failed to emit ack-on-pickup status");
2067            }
2068        }
2069
2070        // Picking up the first command of a submitted session marks the
2071        // session working and keeps it listed in this process's heartbeat
2072        // while the handler runs.
2073        let _pickup = self.pick_up_submitted(&message).await;
2074        let ctx = AgentCtx::new(
2075            &self.laser,
2076            &message,
2077            self.agent.clone(),
2078            self.respond_on.clone(),
2079            self.inbox_route.clone(),
2080            #[cfg(feature = "sign")]
2081            self.signing_key.clone(),
2082        )
2083        .with_request_at(self.log_position(message.id))
2084        .with_sessions(self.sessions.clone(), Arc::clone(&self.control));
2085        // Middleware `before_handle` runs once, in order, before the retry loop.
2086        // A rejection there dead-letters the message without ever running the
2087        // handler (the auth/gatekeeping use), so it is a non-retryable stop.
2088        for middleware in &self.middleware {
2089            if let Err(error) = middleware.before_handle(&message).await {
2090                warn!(%error, source = %message.id, "middleware rejected message before handling, dead-lettering");
2091                self.dead_letter(&message, DeadLetterReason::Rejected, 0, &error.to_string())
2092                    .await?;
2093                return Ok(());
2094            }
2095        }
2096        let mut attempt = 0;
2097        loop {
2098            // A panicking handler becomes a non-retryable error, so the record
2099            // dead-letters as rejected and the consumer keeps running.
2100            let result = std::panic::AssertUnwindSafe(self.handler.handle(&message, &ctx))
2101                .catch_unwind()
2102                .await
2103                .unwrap_or_else(|panic| {
2104                    let reason = panic
2105                        .downcast_ref::<&str>()
2106                        .map(|message| (*message).to_owned())
2107                        .or_else(|| panic.downcast_ref::<String>().cloned())
2108                        .unwrap_or_else(|| "unknown panic".to_owned());
2109                    Err(LaserError::HandlerConfig(format!(
2110                        "handler panicked: {reason}"
2111                    )))
2112                });
2113            // `after_handle` sees every attempt's outcome and one-based number, so a
2114            // metrics middleware counts retries, successes, and terminal failures.
2115            for middleware in &self.middleware {
2116                middleware
2117                    .after_handle(&message, &result, attempt + 1)
2118                    .await;
2119            }
2120            match result {
2121                Ok(()) => {
2122                    debug!(source = %message.id, "message handled");
2123                    return Ok(());
2124                }
2125                Err(error) => {
2126                    if !error.is_retryable() {
2127                        warn!(%error, source = %message.id, "handler rejected message, dead-lettering without retry");
2128                        self.dead_letter(
2129                            &message,
2130                            DeadLetterReason::Rejected,
2131                            attempt + 1,
2132                            &error.to_string(),
2133                        )
2134                        .await?;
2135                        return Ok(());
2136                    }
2137                    if attempt + 1 >= self.retry.max_attempts {
2138                        error!(%error, source = %message.id, attempts = attempt + 1, "handler exhausted retries, dead-lettering");
2139                        self.dead_letter(
2140                            &message,
2141                            DeadLetterReason::RetryExhausted,
2142                            attempt + 1,
2143                            &error.to_string(),
2144                        )
2145                        .await?;
2146                        return Ok(());
2147                    }
2148                    warn!(%error, source = %message.id, attempt = attempt + 1, "handler failed, retrying");
2149                    sleep(self.retry.delay_for(attempt)).await;
2150                    attempt += 1;
2151                }
2152            }
2153        }
2154    }
2155}
2156
2157impl<H> Replay for ReliableWorker<H>
2158where
2159    H: AgentHandler + Sync,
2160{
2161    fn replay<'a>(
2162        &'a self,
2163        received: &'a ConsumerMessage,
2164    ) -> futures::future::BoxFuture<'a, Result<(), LaserError>> {
2165        Box::pin(self.deliver(received, false))
2166    }
2167}
2168
2169// Pre-fills the dedup window from each partition so a freshly started consumer
2170// recognizes duplicates of messages it processed before the restart. Reads only
2171// up to the group's stored (already-consumed) offset and at most `depth` per
2172// partition: reading past the stored offset would pre-mark un-consumed messages
2173// and cause them to be skipped (data loss).
2174async fn warm_dedup_window(
2175    laser: &Laser,
2176    settings: &ReliableConsumer,
2177    dedup: &dyn Deduplicator,
2178) -> Result<(), LaserError> {
2179    let stream = Identifier::named(laser.stream_required()?)?;
2180    let topic_id = Identifier::named(&settings.topic)?;
2181    let Some(details) = laser.client().get_topic(&stream, &topic_id).await? else {
2182        return Ok(());
2183    };
2184    let group_consumer =
2185        iggy::prelude::Consumer::group(Identifier::named(settings.group.as_str())?);
2186    let reader = iggy::prelude::Consumer::new(Identifier::named("laser-dedup-warmer")?);
2187    let depth = u64::try_from(settings.dedup_window).unwrap_or(u64::MAX);
2188    for partition in 0..crate::poll::bounded_partitions(details.partitions_count) {
2189        let Some(offset) = laser
2190            .client()
2191            .get_consumer_offset(&group_consumer, &stream, &topic_id, Some(partition))
2192            .await?
2193        else {
2194            continue;
2195        };
2196        let stored = offset.stored_offset;
2197        let start = stored.saturating_sub(depth.saturating_sub(1));
2198        let count =
2199            u32::try_from(stored.saturating_sub(start).saturating_add(1)).unwrap_or(u32::MAX);
2200        let polled = laser
2201            .client()
2202            .poll_messages(
2203                &stream,
2204                &topic_id,
2205                Some(partition),
2206                &reader,
2207                &PollingStrategy::offset(start),
2208                count,
2209                false,
2210            )
2211            .await?;
2212        for message in polled.messages {
2213            if message.header.offset > stored {
2214                continue;
2215            }
2216            if let Some(key) = warm_dedup_key(
2217                &message,
2218                settings.agent.as_ref(),
2219                settings.understood_features,
2220                #[cfg(feature = "sign")]
2221                settings.verifier.as_deref(),
2222            ) {
2223                dedup.observe(&key).await;
2224            }
2225        }
2226    }
2227    Ok(())
2228}
2229
2230fn warm_dedup_key(
2231    message: &IggyMessage,
2232    agent: Option<&AgentId>,
2233    understood_features: u64,
2234    #[cfg(feature = "sign")] verifier: Option<&crate::sign::KeyRegistry>,
2235) -> Option<String> {
2236    let decoded = decode_agent_record(message, understood_features).ok()?;
2237    if let (Some(target), Some(agent)) = (&decoded.provenance.target_agent_id, agent)
2238        && target != agent
2239    {
2240        return None;
2241    }
2242    #[cfg(feature = "sign")]
2243    if let Some(registry) = verifier {
2244        registry
2245            .verify_observed_at(
2246                decoded.envelope.as_ref()?,
2247                decoded.signature_context.as_ref()?,
2248                decoded.observed_at_micros,
2249            )
2250            .ok()?;
2251    }
2252    dedup_key(&decoded.provenance)
2253}
2254
2255/// The dedup seam: decides whether an idempotency key has been seen before. The
2256/// default `SlidingWindow` is an in-memory bounded set. A durable backend (a
2257/// `StateStore`, or infrastructure-side dedup) is a drop-in. `observe` is async
2258/// and the trait is `dyn`-safe so a premium backend can do I/O behind it.
2259#[async_trait]
2260pub trait Deduplicator: Send + Sync {
2261    // Records the key and returns true if it is new, false if already seen.
2262    async fn observe(&self, key: &str) -> bool;
2263}
2264
2265/// The default `Deduplicator`: an in-memory bounded set of recent keys.
2266pub struct SlidingWindow {
2267    inner: Mutex<DedupWindow>,
2268}
2269
2270impl SlidingWindow {
2271    /// A window that remembers the most recent `capacity` keys.
2272    pub fn new(capacity: usize) -> Self {
2273        Self {
2274            inner: Mutex::new(DedupWindow::new(capacity)),
2275        }
2276    }
2277}
2278
2279#[async_trait]
2280impl Deduplicator for SlidingWindow {
2281    async fn observe(&self, key: &str) -> bool {
2282        self.inner
2283            .lock()
2284            .expect("the dedup mutex is not poisoned")
2285            .insert(key)
2286    }
2287}
2288
2289struct DedupWindow {
2290    capacity: usize,
2291    seen: HashSet<String>,
2292    order: VecDeque<String>,
2293}
2294
2295impl DedupWindow {
2296    fn new(capacity: usize) -> Self {
2297        Self {
2298            capacity: capacity.max(1),
2299            seen: HashSet::new(),
2300            order: VecDeque::new(),
2301        }
2302    }
2303
2304    fn insert(&mut self, key: &str) -> bool {
2305        if self.seen.contains(key) {
2306            return false;
2307        }
2308        if self.order.len() >= self.capacity
2309            && let Some(evicted) = self.order.pop_front()
2310        {
2311            self.seen.remove(&evicted);
2312        }
2313        self.seen.insert(key.to_owned());
2314        self.order.push_back(key.to_owned());
2315        true
2316    }
2317}
2318
2319#[cfg(test)]
2320mod tests {
2321    use super::*;
2322
2323    #[test]
2324    fn given_consumer_failures_when_backing_off_then_should_jitter_within_the_upper_half() {
2325        for attempt in [1, 3, 10] {
2326            let ceiling = crate::retry::CONSUMER.ceiling(attempt);
2327            for _ in 0..64 {
2328                let delay = backoff_for(attempt);
2329                assert!(
2330                    delay <= ceiling && delay >= ceiling / 2,
2331                    "attempt {attempt}: {delay:?}"
2332                );
2333            }
2334        }
2335        assert_eq!(crate::retry::CONSUMER.ceiling(1), Duration::from_millis(50));
2336        assert_eq!(crate::retry::CONSUMER.ceiling(40), Duration::from_secs(1));
2337    }
2338
2339    #[test]
2340    fn given_an_agdx_command_when_warming_dedup_then_should_restore_the_scoped_key() {
2341        let envelope = AgentEnvelope::command(
2342            laser_wire::agent::RecordId::from_u128(3),
2343            laser_wire::agent::ConversationId::from_u128(1),
2344            "author".parse().expect("source parses"),
2345            laser_wire::agent::CorrelationId::from_u128(2),
2346            b"work".to_vec(),
2347        )
2348        .with_operation(laser_wire::agent::OPERATION_CHAT)
2349        .with_target("worker".parse().expect("target parses"))
2350        .with_idempotency_key("attempt-1".parse().expect("key parses"));
2351        let mut headers = BTreeMap::new();
2352        headers.insert(
2353            HeaderKey::from_str(AGENT_VERSION).expect("version key"),
2354            HeaderValue::from(AGENT_OP_VERSION),
2355        );
2356        let message = IggyMessage::builder()
2357            .payload(bytes::Bytes::from(
2358                encode_named(&envelope).expect("envelope encodes"),
2359            ))
2360            .user_headers(headers)
2361            .build()
2362            .expect("message builds");
2363        let worker: AgentId = "worker".parse().expect("worker parses");
2364        let other: AgentId = "other".parse().expect("other parses");
2365
2366        assert_eq!(
2367            warm_dedup_key(
2368                &message,
2369                Some(&worker),
2370                features::NONE,
2371                #[cfg(feature = "sign")]
2372                None,
2373            ),
2374            Some(format!("author{DEDUP_SCOPE_SEP}attempt-1"))
2375        );
2376        assert!(
2377            warm_dedup_key(
2378                &message,
2379                Some(&other),
2380                features::NONE,
2381                #[cfg(feature = "sign")]
2382                None,
2383            )
2384            .is_none()
2385        );
2386    }
2387
2388    #[test]
2389    fn given_a_small_positive_fence_when_decoded_then_should_preserve_the_token() {
2390        let envelope = AgentEnvelope::command(
2391            laser_wire::agent::RecordId::from_u128(3),
2392            laser_wire::agent::ConversationId::from_u128(1),
2393            "orchestrator".parse().expect("agent id parses"),
2394            laser_wire::agent::CorrelationId::from_u128(2),
2395            Vec::new(),
2396        )
2397        .with_metadata(FENCE, laser_wire::query::Value::Int(7));
2398
2399        assert_eq!(provenance_from_envelope(&envelope).fence_token, Some(7));
2400    }
2401
2402    #[test]
2403    fn given_an_envelope_with_usage_when_decoded_then_should_keep_token_counts() {
2404        let envelope = AgentEnvelope::command(
2405            laser_wire::agent::RecordId::from_u128(3),
2406            laser_wire::agent::ConversationId::from_u128(1),
2407            "author".parse().expect("agent id parses"),
2408            laser_wire::agent::CorrelationId::from_u128(2),
2409            b"work".to_vec(),
2410        )
2411        .with_operation(laser_wire::agent::OPERATION_CHAT)
2412        .with_usage(laser_wire::agent::TokenUsage {
2413            input_tokens: 13,
2414            output_tokens: 21,
2415            ..Default::default()
2416        });
2417
2418        let usage = provenance_from_envelope(&envelope)
2419            .usage
2420            .expect("usage should be preserved");
2421        assert_eq!(usage.input_tokens, Some(13));
2422        assert_eq!(usage.output_tokens, Some(21));
2423    }
2424
2425    #[test]
2426    fn given_a_seen_key_when_inserting_again_then_should_report_a_duplicate() {
2427        let mut window = DedupWindow::new(8);
2428        assert!(window.insert("a"));
2429        assert!(!window.insert("a"));
2430        assert!(window.insert("b"));
2431    }
2432
2433    #[test]
2434    fn given_a_full_window_when_inserting_then_should_evict_the_oldest_key() {
2435        let mut window = DedupWindow::new(2);
2436        assert!(window.insert("a"));
2437        assert!(window.insert("b"));
2438        assert!(window.insert("c"));
2439        assert!(window.insert("a"));
2440    }
2441
2442    #[test]
2443    fn given_increasing_attempts_when_computing_backoff_then_should_grow_and_stay_bounded() {
2444        let policy = RetryPolicy::backoff(5, Duration::from_millis(100));
2445        assert_eq!(policy.delay_for(0), Duration::from_millis(100));
2446        assert_eq!(policy.delay_for(1), Duration::from_millis(200));
2447        assert_eq!(policy.delay_for(2), Duration::from_millis(400));
2448        assert!(policy.delay_for(60) >= policy.delay_for(2));
2449    }
2450
2451    #[test]
2452    fn given_two_agents_with_the_same_idempotency_key_when_scoped_then_should_differ() {
2453        let conversation = ConversationId::new();
2454        let with_agent = |agent: &str| {
2455            Provenance::builder()
2456                .conversation_id(conversation)
2457                .agent(agent.parse().expect("valid agent id"))
2458                .idempotency_key("attempt-1".to_owned())
2459                .build()
2460        };
2461        // Same idempotency key, different producers, so the scoped keys differ and
2462        // one cannot suppress the other.
2463        assert_ne!(
2464            dedup_key(&with_agent("planner")),
2465            dedup_key(&with_agent("worker"))
2466        );
2467        // No agent falls back to the bare key.
2468        let anon = Provenance::builder()
2469            .conversation_id(conversation)
2470            .idempotency_key("attempt-1".to_owned())
2471            .build();
2472        assert_eq!(dedup_key(&anon).as_deref(), Some("attempt-1"));
2473    }
2474
2475    #[test]
2476    fn given_a_fence_gate_when_a_stale_token_arrives_then_should_drop_it_and_keep_per_task_scope() {
2477        let high_water = dashmap::DashMap::new();
2478        let sweep = std::sync::atomic::AtomicU64::new(0);
2479        let task_a = ConversationId::new();
2480        let task_b = ConversationId::new();
2481
2482        // First grant accepted, advances the high water.
2483        assert!(accept_fence(&high_water, &sweep, task_a, 1, 100));
2484        // A fresh holder at a higher fence accepted.
2485        assert!(accept_fence(&high_water, &sweep, task_a, 2, 101));
2486        // The original holder's stale replay (below the high water) is dropped.
2487        assert!(!accept_fence(&high_water, &sweep, task_a, 1, 102));
2488        // An equal fence (the same holder's legitimate retry) is accepted, dedup
2489        // handles the duplicate downstream.
2490        assert!(accept_fence(&high_water, &sweep, task_a, 2, 103));
2491        // A different task keeps its own high water, so a low fence there is fine.
2492        assert!(accept_fence(&high_water, &sweep, task_b, 1, 104));
2493    }
2494}