klieo-core 0.41.0

Core traits + runtime for the klieo agent framework.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
//! Per-replica registry of in-flight cancellation tokens.
//!
//! When klieo runs as multiple replicas behind a shared bus, a cancel
//! request published on a wildcard subject (such as
//! `klieo.a2a.cancel.{task_id}` or `klieo.mcp.cancel.{progressToken}`)
//! reaches every replica, but only one replica actually owns the
//! in-flight invocation for a given id. Each replica keeps a
//! [`CancelRegistry`] mapping ids it owns to their
//! [`CancellationToken`]; a background task subscribed to the wildcard
//! cancel subject calls [`CancelRegistry::cancel`] on every inbound
//! message. The replica that owns the id fires its token; replicas
//! that don't own it observe a no-op lookup miss.
//!
//! The expected lifecycle on the owning replica is:
//!
//! 1. Caller creates a fresh [`CancellationToken`] for the new
//!    invocation and calls [`CancelRegistry::register`] with the
//!    stream-specific id (A2A task id, MCP progress token, etc.).
//! 2. The invocation runs to completion or is aborted via the token.
//! 3. Caller calls [`CancelRegistry::deregister`] in a finally-style
//!    cleanup so the map does not leak entries past invocation end.

use std::collections::HashMap;
use std::hash::Hash;
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use std::task::{Context, Poll};

use bytes::Bytes;
use futures_core::Stream;
use tokio_util::sync::CancellationToken;

use crate::bus::{validate_subject_token, Headers, Pubsub};
use crate::error::BusError;

/// Per-replica registry mapping in-flight stream ids to their
/// [`CancellationToken`]. Cloning the registry yields a handle that
/// shares the same underlying map, so the wildcard cancel-subject
/// subscription task and the invoke-handling task can both hold one.
///
/// The key type is generic so the same shape serves both A2A (task id
/// as `String`) and MCP (progress token as `String`) without coupling
/// `klieo-core` to either transport's id type.
#[derive(Clone, Debug, Default)]
pub struct CancelRegistry<K>
where
    K: Eq + Hash + Clone,
{
    inner: Arc<RwLock<HashMap<K, CancellationToken>>>,
}

impl<K> CancelRegistry<K>
where
    K: Eq + Hash + Clone + std::fmt::Debug,
{
    /// Create an empty registry. Equivalent to
    /// [`CancelRegistry::default`].
    pub fn new() -> Self {
        Self {
            inner: Arc::new(RwLock::new(HashMap::new())),
        }
    }

    /// Record that this replica owns the in-flight invocation
    /// identified by `id`, with the supplied token. Call this at
    /// invoke start, before the invocation can plausibly receive a
    /// cross-replica cancel for that id.
    ///
    /// An existing entry for `id` is overwritten; the caller is
    /// expected to keep ids unique per invocation.
    #[tracing::instrument(skip_all, fields(klieo.cancel.key = ?id), level = "debug")]
    pub fn register(&self, id: K, token: CancellationToken) {
        self.inner
            .write()
            .expect("CancelRegistry lock poisoned")
            .insert(id, token);
    }

    /// Remove the entry for `id`. Call this when the invocation
    /// finishes (success or failure) so the map does not retain
    /// tokens past invocation end. A missing entry is silently
    /// ignored — deregister-after-cancel is a normal interleaving.
    #[tracing::instrument(skip_all, fields(klieo.cancel.key = ?id), level = "debug")]
    pub fn deregister(&self, id: &K) {
        self.inner
            .write()
            .expect("CancelRegistry lock poisoned")
            .remove(id);
    }

    /// Fire the token registered for `id` and return `true`. When no
    /// entry exists for `id` return `false` without panicking — this
    /// is the expected path on replicas that don't own the cancelled
    /// invocation, and on cancel messages that arrive after the
    /// invocation already finished.
    #[tracing::instrument(
        skip_all,
        fields(klieo.cancel.key = ?id, klieo.cancel.hit = tracing::field::Empty),
        level = "debug",
    )]
    pub fn cancel(&self, id: &K) -> bool {
        let guard = self.inner.read().expect("CancelRegistry lock poisoned");
        let hit = match guard.get(id) {
            Some(token) => {
                token.cancel();
                true
            }
            None => false,
        };
        tracing::Span::current().record("klieo.cancel.hit", hit);
        hit
    }
}

/// Publish a cross-replica cancel signal for `id` on the subject
/// `format!("{subject_prefix}{id}")`. The body is empty — subscribers
/// only need the subject to identify which in-flight invocation to
/// cancel via their local [`CancelRegistry`].
///
/// Callers pass the transport-specific subject prefix, e.g.
/// `"klieo.a2a.cancel."` or `"klieo.mcp.cancel."`. `id` is validated
/// against [`validate_subject_token`] before subject construction;
/// metacharacters (`.`, `*`, `>`, whitespace, non-ASCII) yield
/// `BusError::Invalid(_)`, preventing a caller-controlled id from
/// collapsing or wildcarding the subject namespace (CWE-74).
///
/// # Security
///
/// Cancel signals share the progressToken-as-credential threat model
/// documented for resume in ADR-018. The cancel subject embeds an
/// opaque caller-supplied id (`task_id` for A2A, `progressToken` for
/// MCP); any caller who knows the id can publish a cancel that the
/// owning replica will honour. Operators MUST mint unguessable ids
/// (UUID v4 or stronger) and gate per-tenant authorisation BEFORE
/// the request reaches the dispatcher — without that, knowledge of
/// the id grants the ability to cancel the invocation across tenant
/// boundaries (CWE-639 IDOR). See ADR-018 and ADR-019.
pub async fn publish_cancel_signal(
    pubsub: &Arc<dyn Pubsub>,
    subject_prefix: &str,
    id: &str,
) -> Result<(), BusError> {
    validate_subject_token(id)?;
    let subject = format!("{subject_prefix}{id}");
    pubsub
        .publish(&subject, Bytes::new(), Headers::default())
        .await
}

/// Spawn the wildcard cancel-subject background subscriber for one
/// transport (A2A or MCP).
///
/// Required for multi-replica deployments — without it, only same-
/// replica drop-cancel works. Subscribes to `subject_pattern` (e.g.
/// `"klieo.a2a.cancel.>"`) with an ephemeral durable name
/// `klieo-cancel-{uuid}`. On each delivered [`crate::Msg`]: parse
/// the per-invoke id by stripping `subject_prefix` from
/// `msg.subject`, ack the message, then look up the id in
/// `registry` and fire the matching token (or no-op on lookup miss
/// — the originating replica is elsewhere).
///
/// Subscribe failure at startup logs `error` at `log_target` and
/// the background task exits — replica falls back to single-replica
/// semantics. Per-message bus errors log `warn` and the loop
/// continues. Ack failures log `warn` (and the message will be
/// redelivered by the bus); the loop continues so a transient ack
/// error does not stall the cancel pipeline.
pub fn spawn_wildcard_cancel_subscriber(
    pubsub: Arc<dyn Pubsub>,
    subject_pattern: String,
    subject_prefix: String,
    registry: CancelRegistry<String>,
    log_target: &'static str,
) {
    // `tracing` macros require `target` as a compile-time literal,
    // so the runtime `log_target` is surfaced as a structured field
    // (`transport`) on each event instead. Operators filtering on
    // `klieo.cancel` see both transports unified; field-level
    // filtering on `transport = "a2a.cancel"` / `"mcp.cancel"`
    // recovers the per-transport view the inline loops previously
    // provided.
    tokio::spawn(async move {
        let durable =
            crate::ids::DurableName::new(format!("klieo-cancel-{}", uuid::Uuid::new_v4()));
        let mut stream = match pubsub.subscribe(&subject_pattern, durable).await {
            Ok(s) => s,
            Err(e) => {
                tracing::error!(
                    target: "klieo.cancel",
                    transport = %log_target,
                    pattern = %subject_pattern,
                    error = %e,
                    "wildcard cancel subscription failed; \
                     replica falls back to single-replica semantics",
                );
                return;
            }
        };
        while let Some(item) = tokio_stream::StreamExt::next(&mut stream).await {
            match item {
                Ok(msg) => {
                    let id = msg
                        .subject
                        .strip_prefix(subject_prefix.as_str())
                        .unwrap_or_default()
                        .to_string();
                    let subject = msg.subject.clone();
                    let ack = msg.ack;
                    let span = tracing::info_span!(
                        "cancel_dispatch",
                        messaging.system = "klieo-bus",
                        messaging.destination = %subject,
                        messaging.operation = "receive",
                        transport = %log_target,
                    );
                    // Extract publisher's W3C tracecontext from bus
                    // headers so the dispatch span stitches under the
                    // originating replica's span (cluster 0.23,
                    // ADR-023). Requires the `otel` feature.
                    #[cfg(feature = "otel")]
                    {
                        use tracing_opentelemetry::OpenTelemetrySpanExt as _;
                        let parent_cx = crate::bus::extract_traceparent(&msg.headers);
                        span.set_parent(parent_cx);
                    }
                    use tracing::Instrument as _;
                    let registry = registry.clone();
                    async move {
                        if let Err(e) = ack.ack().await {
                            tracing::warn!(
                                target: "klieo.cancel",
                                transport = %log_target,
                                subject = %subject,
                                error = %e,
                                "cancel ack failed; expect redelivery",
                            );
                        }
                        if !id.is_empty() {
                            let hit = registry.cancel(&id);
                            tracing::debug!(
                                target: "klieo.cancel",
                                transport = %log_target,
                                id = %id,
                                hit = hit,
                                "cancel signal dispatched",
                            );
                        }
                    }
                    .instrument(span)
                    .await;
                }
                Err(e) => {
                    tracing::warn!(
                        target: "klieo.cancel",
                        transport = %log_target,
                        error = %e,
                        "cancel subscription stream error; continuing",
                    );
                }
            }
        }
    });
}

/// Drop-time helper that publishes a cross-replica cancel signal on
/// the supplied subject. Returns immediately; the publish is best-
/// effort via `tokio::spawn`. If no tokio runtime is active (e.g.
/// synchronous shutdown), the publish is skipped and a `warn` is
/// emitted instead of panicking.
///
/// Caller is expected to invoke this from within `Drop::drop` AFTER
/// the local `DropGuard` has fired — the bus publish merely fans the
/// signal out to peer replicas. `subject` is moved in (use
/// [`std::mem::take`] on the field). Empty `subject` short-circuits
/// to a no-op so call-sites do not have to guard separately.
///
/// `log_target` is surfaced as a structured `transport` field on
/// `tracing` events rather than the macro `target:` slot, because
/// `tracing` requires the target to be a compile-time literal. All
/// events fire under the unified `klieo.cancel` target — operators
/// filter on `transport = "a2a.cancel"` / `"mcp.cancel"` for the
/// per-transport view. Matches the precedent set by
/// [`spawn_wildcard_cancel_subscriber`] in this module.
///
/// `permits` optionally bounds the concurrency of in-flight drop
/// publishes per transport. When `Some(sem)` the call uses
/// `try_acquire_owned` — on saturation the publish is dropped + a
/// `warn` fires (preferred over flooding the tokio runtime with
/// unbounded spawns under a slow bus backend). The acquired permit
/// is moved into the spawned task and released on task end. When
/// `None` the historical unbounded behaviour is preserved so
/// pre-existing call sites keep their semantics until they wire a
/// semaphore in.
pub fn spawn_drop_publish(
    pubsub: Arc<dyn Pubsub>,
    subject: String,
    log_target: &'static str,
    permits: Option<Arc<tokio::sync::Semaphore>>,
    trace_headers: Headers,
) {
    if subject.is_empty() {
        return;
    }
    let permit = match permits.as_ref() {
        Some(sem) => match sem.clone().try_acquire_owned() {
            Ok(p) => Some(p),
            Err(_) => {
                tracing::warn!(
                    target: "klieo.cancel",
                    transport = log_target,
                    subject = %subject,
                    available_permits = sem.available_permits(),
                    "cancel publish dropped: concurrency cap reached",
                );
                return;
            }
        },
        None => None,
    };
    match tokio::runtime::Handle::try_current() {
        Ok(handle) => {
            handle.spawn(async move {
                let _permit = permit;
                if let Err(e) = pubsub.publish(&subject, Bytes::new(), trace_headers).await {
                    tracing::warn!(
                        target: "klieo.cancel",
                        transport = %log_target,
                        subject = %subject,
                        error = %e,
                        "cancel publish failed; cross-replica cancel degraded",
                    );
                }
            });
        }
        Err(_) => {
            tracing::warn!(
                target: "klieo.cancel",
                transport = %log_target,
                subject = %subject,
                "cancel publish skipped: no tokio runtime active during Drop",
            );
        }
    }
}

/// Spawn a best-effort bus publish bounded by `permits`. If the
/// semaphore has no available permits the publish is dropped + a
/// `tracing::warn!` fires; the call returns immediately and never
/// blocks the caller. Used by SSE yield-publish hot paths
/// (`stream_tools_call`, `TaskEventSink::send`) where dropping a
/// non-critical fanout event is preferable to flooding the runtime
/// with unbounded tasks.
///
/// `log_target` is a structured `transport` field on a
/// `target: "klieo.cancel"` event (tracing requires const-literal
/// targets).
pub fn spawn_publish_bounded(
    pubsub: Arc<dyn Pubsub>,
    subject: String,
    payload: Bytes,
    log_target: &'static str,
    permits: Arc<tokio::sync::Semaphore>,
    trace_headers: Headers,
) {
    let permit = match permits.clone().try_acquire_owned() {
        Ok(p) => p,
        Err(_) => {
            tracing::warn!(
                target: "klieo.cancel",
                transport = log_target,
                subject = %subject,
                available_permits = permits.available_permits(),
                "publish dropped: concurrency cap reached",
            );
            return;
        }
    };
    match tokio::runtime::Handle::try_current() {
        Ok(handle) => {
            handle.spawn(async move {
                let _permit = permit;
                if let Err(e) = pubsub.publish(&subject, payload, trace_headers).await {
                    tracing::warn!(
                        target: "klieo.cancel",
                        transport = log_target,
                        subject = %subject,
                        error = %e,
                        "publish failed",
                    );
                }
            });
        }
        Err(_) => {
            tracing::warn!(
                target: "klieo.cancel",
                transport = log_target,
                subject = %subject,
                "publish skipped: no tokio runtime active",
            );
        }
    }
}

/// Stream wrapper that deregisters an entry keyed by `id` from
/// `registry` on `Drop`. Use in HTTP-layer streaming paths to ensure
/// the per-invoke registry entry installed at invoke start is
/// removed when the response body terminates (final event, server
/// cancel, or client disconnect).
///
/// `Stream` is implemented by delegating `poll_next` to the inner
/// stream; this is a transparent wrapper at the polling layer.
///
/// `K` is fixed at `String` because both current call sites (A2A
/// `task_id`, MCP `progressToken`) use that key type. Generify
/// further when a non-`String` key appears.
///
/// An empty `id` is treated as a no-op on drop so callers that lack
/// a meaningful id (e.g. A2A `SendStreamingMessage` paths where
/// `task_id` is not known at SSE-build time) can wrap unconditionally.
pub struct RegistryDeregisterOnDrop<S> {
    inner: S,
    registry: CancelRegistry<String>,
    id: String,
}

impl<S> RegistryDeregisterOnDrop<S> {
    /// Wrap `inner` so that on `Drop` the entry keyed by `id` is
    /// removed from `registry`. Empty `id` is allowed and yields a
    /// no-op on drop.
    pub fn new(inner: S, registry: CancelRegistry<String>, id: String) -> Self {
        Self {
            inner,
            registry,
            id,
        }
    }
}

impl<S: Stream + Unpin> Stream for RegistryDeregisterOnDrop<S> {
    type Item = S::Item;
    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
        Pin::new(&mut self.inner).poll_next(cx)
    }
}

impl<S> Drop for RegistryDeregisterOnDrop<S> {
    fn drop(&mut self) {
        if !self.id.is_empty() {
            self.registry.deregister(&self.id);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::bus::MsgStream;
    use crate::ids::DurableName;
    use async_trait::async_trait;
    use std::time::Duration;
    use tokio::sync::{mpsc, Semaphore};

    /// Recording `Pubsub` used to assert publish side-effects without
    /// pulling in `klieo-bus-memory` as a dev-dep (which would form a
    /// cyclic compile graph). Publishes push `(subject, payload)` onto
    /// an mpsc; `subscribe` is unused by these tests and yields an
    /// `Unimplemented` error so a misuse is loud.
    struct RecordingPubsub {
        tx: mpsc::UnboundedSender<(String, Bytes)>,
    }

    #[async_trait]
    impl Pubsub for RecordingPubsub {
        async fn publish(
            &self,
            subject: &str,
            payload: Bytes,
            _headers: Headers,
        ) -> Result<(), crate::error::BusError> {
            let _ = self.tx.send((subject.to_string(), payload));
            Ok(())
        }

        async fn subscribe(
            &self,
            _subject: &str,
            _durable: DurableName,
        ) -> Result<MsgStream, crate::error::BusError> {
            Err(crate::error::BusError::Unsupported(
                "RecordingPubsub::subscribe is not used in tests".into(),
            ))
        }
    }

    fn recording_pubsub() -> (Arc<dyn Pubsub>, mpsc::UnboundedReceiver<(String, Bytes)>) {
        let (tx, rx) = mpsc::unbounded_channel();
        (Arc::new(RecordingPubsub { tx }), rx)
    }

    #[test]
    fn register_then_cancel_fires_token() {
        let registry: CancelRegistry<String> = CancelRegistry::new();
        let token = CancellationToken::new();
        registry.register("task-1".to_string(), token.clone());

        assert!(!token.is_cancelled());

        let hit = registry.cancel(&"task-1".to_string());

        assert!(hit, "cancel on registered id returns true");
        assert!(token.is_cancelled(), "registered token fires on cancel");
    }

    #[test]
    fn deregister_removes_entry() {
        let registry: CancelRegistry<String> = CancelRegistry::new();
        let token = CancellationToken::new();
        registry.register("task-2".to_string(), token.clone());

        registry.deregister(&"task-2".to_string());

        let hit = registry.cancel(&"task-2".to_string());

        assert!(!hit, "cancel after deregister returns false");
        assert!(
            !token.is_cancelled(),
            "deregistered token is not fired by later cancel",
        );
    }

    #[test]
    fn cancel_on_missing_key_is_noop() {
        let registry: CancelRegistry<String> = CancelRegistry::new();

        let hit = registry.cancel(&"never".to_string());

        assert!(!hit, "cancel on absent id returns false");
    }

    #[tokio::test(flavor = "current_thread")]
    async fn spawn_publish_bounded_drops_on_saturation() {
        let (pubsub, mut rx) = recording_pubsub();
        // Keep a second sender alive so the channel stays open while
        // the bounded helper drops its pubsub clone on the saturation
        // path; otherwise `rx.recv()` returns `Ok(None)` immediately on
        // sender-drop and masquerades as a delivered message.
        let _keepalive = pubsub.clone();
        let permits = Arc::new(Semaphore::new(0));

        spawn_publish_bounded(
            pubsub,
            "subj.saturated".to_string(),
            Bytes::from_static(b"x"),
            "test.cancel",
            permits,
            Headers::default(),
        );

        let recv = tokio::time::timeout(Duration::from_millis(100), rx.recv()).await;
        assert!(
            recv.is_err(),
            "no publish should reach recorder under zero-permit saturation"
        );
    }

    #[tokio::test(flavor = "current_thread")]
    async fn spawn_publish_bounded_publishes_with_available_permit() {
        let (pubsub, mut rx) = recording_pubsub();
        let permits = Arc::new(Semaphore::new(1));

        spawn_publish_bounded(
            pubsub,
            "subj.ok".to_string(),
            Bytes::from_static(b"hello"),
            "test.cancel",
            permits,
            Headers::default(),
        );

        let recv = tokio::time::timeout(Duration::from_millis(500), rx.recv())
            .await
            .expect("publish should arrive within 500ms when a permit is available");
        let (subject, payload) = recv.expect("recorder channel closed unexpectedly");
        assert_eq!(subject, "subj.ok");
        assert_eq!(payload.as_ref(), b"hello");
    }

    #[tokio::test(flavor = "current_thread")]
    async fn spawn_drop_publish_with_permits_drops_on_saturation() {
        let (pubsub, mut rx) = recording_pubsub();
        // See `spawn_publish_bounded_drops_on_saturation` — keep the
        // sender side of the recorder alive across the saturation path
        // so `rx.recv()` blocks for the full timeout instead of
        // returning `Ok(None)` on the inner Arc drop.
        let _keepalive = pubsub.clone();
        let permits = Arc::new(Semaphore::new(0));

        spawn_drop_publish(
            pubsub,
            "subj.drop.saturated".to_string(),
            "test.cancel",
            Some(permits),
            Headers::default(),
        );

        let recv = tokio::time::timeout(Duration::from_millis(100), rx.recv()).await;
        assert!(
            recv.is_err(),
            "spawn_drop_publish with zero permits must not publish",
        );
    }
}