agentplane 0.31.0

Durable, replayable agent runtime — the journal is the plan of record
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
//! From *instrumented* to *monitored*: the last mile, in runnable form.
//!
//! ```sh
//! cargo run --example observability
//! ```
//!
//! This crate ships no exporter, for the same reason it ships no policy engine:
//! picking your collector is not its decision. That is the right call and it
//! leaves a gap, because the distance between **instrumented** and
//! **monitored** is exactly where an evaluation writes *not fulfilled*. Three
//! things stand in that gap, none of them obvious from the API docs, and each
//! one is a real bridge layer here rather than a snippet in prose:
//!
//! 1. **Latency must exclude replayed effects.** A replayed effect reads its
//!    result from the journal and never touches the world. Counted as latency
//!    it does not add noise — it silently *improves* your p99 in proportion to
//!    how much recovery you are doing, which is precisely when you want the
//!    number to be honest.
//!
//!    The runtime already makes this easy, in a way worth stating because it
//!    is not what a reader assumes: **a replayed effect opens no span at all.**
//!    It emits a `debug` *event* on the `agentplane.effect` target carrying
//!    `replayed = true`, and the `agentplane.effects_replayed` counter. So a
//!    histogram built from *spans* is clean by construction. What is not safe
//!    is keying on the **target** and treating everything on it as a span —
//!    that view sees both, and it is the one in which replays contaminate
//!    latency. The bridge below separates them and asserts on the split.
//!
//! 2. **Gauges come from a census, not from increments.** "Open cases" cannot
//!    be counted by incrementing on open and decrementing on close: a crash
//!    between the state change and the emission loses a decrement forever, and
//!    the dashboard slowly invents cases that do not exist. The runtime
//!    therefore emits gauges only when something queries the stores — the
//!    sweeper — so *scheduling the sweep is scheduling your gauges*. A plane
//!    with no sweep loop has counters and no gauges, and nothing says so.
//!
//! 3. **`SweepReport::needs_attention()` is the alert predicate.** It is
//!    already written, it already folds in breaches, expiries, dead letters,
//!    saturation, failed recoveries and lost evidence, and it is the one call
//!    an alert rule should key on. Re-deriving it from individual counters is
//!    how a new failure mode ends up alerting on nothing.
//!
//! # The OTLP wiring, verbatim
//!
//! What runs below is a `tracing_subscriber::Layer` that reads exactly the
//! fields an OTLP bridge reads. To send them to a real collector, keep the
//! filtering rules and swap the sink:
//!
//! ```toml
//! # Cargo.toml
//! opentelemetry            = "0.32"
//! opentelemetry_sdk        = { version = "0.32", features = ["rt-tokio"] }
//! opentelemetry-otlp       = { version = "0.32", features = ["grpc-tonic", "metrics"] }
//! tracing-opentelemetry    = "0.33"
//! tracing-subscriber       = { version = "0.3", features = ["registry", "env-filter"] }
//! ```
//!
//! ```ignore
//! use tracing_subscriber::prelude::*;
//!
//! let tracer = opentelemetry_otlp::SpanExporter::builder().with_tonic().build()?;
//! let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder()
//!     .with_batch_exporter(tracer)
//!     .build();
//!
//! tracing_subscriber::registry()
//!     // Spans → OTLP. `agentplane.run` / `.step` / `.effect` nest correctly,
//!     // so the collector derives every duration; see `runtime::metrics` on
//!     // why this crate measures none of them itself.
//!     .with(tracing_opentelemetry::layer().with_tracer(
//!         opentelemetry::trace::TracerProvider::tracer(&provider, "agentplane"),
//!     ))
//!     // Metric events → your meter. `MetricBridge` below is the whole of it:
//!     // one target, five fixed fields, and the catalogue names the label.
//!     .with(MetricBridge::default())
//!     // The one rule that is not mechanical. Everything else a bridge does
//!     // for you; this it cannot guess.
//!     .with(DropReplayedEffects)
//!     .init();
//! ```
//!
//! Then schedule the two loops — `sweep` for gauges and the backlog, `drill`
//! for the recovery rehearsal — or run `agentplane serve --sweep-every 30
//! --drill-every 86400`, which is the same two loops with no Rust.

use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};

use agentplane::case::CaseStore;
use agentplane::core::{Outcome, Skill, SkillDescriptor, SkillError, Tainted};
use agentplane::journal::JournalStore;
use agentplane::runtime::effects::Recorded;
use agentplane::runtime::telemetry;
use agentplane::runtime::{Mode, RunStatus, Runtime, StepCtx};
use agentplane::store::RedbStore;
use serde_json::{Value, json};
use tracing::field::{Field, Visit};
use tracing_subscriber::Layer;
use tracing_subscriber::layer::{Context, SubscriberExt};
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::util::SubscriberInitExt;

// ── What a collector would hold ─────────────────────────────────────────────

/// Everything the bridge decided to keep, so the example can assert on it.
///
/// A real deployment's equivalent is the collector; the shape is the same
/// because the decisions are the same.
#[derive(Debug, Default)]
struct Collected {
    /// Effect spans that reached the world, by kind. The latency series.
    live_effects: Vec<String>,
    /// Effect spans dropped because they were replayed.
    ///
    /// Counted rather than discarded silently: *how much of this plane's work
    /// is recovery* is a real question, and it is a different series from
    /// latency rather than a contaminant in it.
    replayed_effects: usize,
    /// `metric` → `dim` → summed value. What a meter would hold.
    metrics: BTreeMap<String, BTreeMap<String, f64>>,
}

type Sink = Arc<Mutex<Collected>>;

// ── 1. Latency that excludes replay ─────────────────────────────────────────

/// Keeps live effect spans out of the same series as replayed ones.
///
/// The whole rule: an `agentplane.effect` span carrying
/// `agentplane.effect.replayed = true` performed nothing. Its duration is a
/// journal read, and a histogram that mixes the two reports better latency the
/// more recovery a plane is doing.
///
/// Implemented on span **creation**, where the field is recorded, rather than on
/// close: `tracing` fields are written once at creation, so this is the cheap
/// point and it is also the point at which a real bridge can decline to start
/// an `OTel` span at all.
struct DropReplayedEffects(Sink);

impl<S> Layer<S> for DropReplayedEffects
where
    S: tracing::Subscriber + for<'a> LookupSpan<'a>,
{
    /// A **span** on this target is a live dispatch. Its duration is a real
    /// call, and it is the only thing a latency series should contain.
    fn on_new_span(
        &self,
        attrs: &tracing::span::Attributes<'_>,
        _id: &tracing::span::Id,
        _ctx: Context<'_, S>,
    ) {
        if attrs.metadata().name() != telemetry::EFFECT_SPAN {
            return;
        }
        let mut fields = Fields::default();
        attrs.record(&mut fields);
        // Belt and braces. The field is always `false` on a live span today,
        // and reading it costs nothing — a bridge that assumed the invariant
        // and was wrong would report the fastest p99 in the company.
        if fields.bools.get(telemetry::EFFECT_REPLAYED).copied() == Some(true) {
            self.0.lock().expect("collector").replayed_effects += 1;
            return;
        }
        let kind = fields
            .strings
            .get(telemetry::EFFECT_KIND)
            .cloned()
            .unwrap_or_else(|| "unknown".to_owned());
        self.0.lock().expect("collector").live_effects.push(kind);
    }

    /// An **event** on the same target is a replay: no world contact, no
    /// duration, nothing for a histogram. Counted, because *how much of this
    /// plane's work is recovery* is a real question — just a different series.
    fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
        if event.metadata().target() != telemetry::EFFECT_SPAN {
            return;
        }
        let mut fields = Fields::default();
        event.record(&mut fields);
        if fields.bools.get("replayed").copied() == Some(true) {
            self.0.lock().expect("collector").replayed_effects += 1;
        }
    }
}

// ── 2. Metric events → a meter ──────────────────────────────────────────────

/// Every metric this plane emits, on one target with five fixed fields.
///
/// `metric`, `kind`, `unit`, `value`, `dim`. The *meaning* of `dim` is declared
/// per instrument in the catalogue, so a bridge reads
/// [`metrics::CATALOGUE`](agentplane::runtime::metrics::CATALOGUE) once at
/// startup and knows the label name for every stream — rather than inferring it
/// from samples, which is how a label ends up named `dim` on a dashboard.
struct MetricBridge(Sink);

impl<S: tracing::Subscriber> Layer<S> for MetricBridge {
    fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
        if event.metadata().target() != agentplane::runtime::metrics::METRIC {
            return;
        }
        let mut fields = Fields::default();
        event.record(&mut fields);
        let Some(metric) = fields.strings.get("metric") else {
            return;
        };
        let dim = fields
            .strings
            .get("dim")
            .cloned()
            .unwrap_or_else(|| "-".to_owned());
        let value = fields.numbers.get("value").copied().unwrap_or_default();

        let mut out = self.0.lock().expect("collector");
        let series = out.metrics.entry(metric.clone()).or_default();
        // A gauge is *replaced*, a counter accumulates. The catalogue says
        // which, and reading it is the difference between "open cases now" and
        // "every open case this process ever saw".
        let is_gauge = agentplane::runtime::metrics::CATALOGUE
            .iter()
            .find(|i| i.name == metric.as_str())
            .is_some_and(|i| matches!(i.kind, agentplane::runtime::metrics::Kind::Gauge));
        let slot = series.entry(dim).or_default();
        if is_gauge {
            *slot = value;
        } else {
            *slot += value;
        }
    }
}

/// Pulls typed fields out of a span or event without allocating a JSON tree.
#[derive(Default)]
struct Fields {
    strings: BTreeMap<String, String>,
    bools: BTreeMap<String, bool>,
    numbers: BTreeMap<String, f64>,
}

#[allow(clippy::cast_precision_loss)]
impl Visit for Fields {
    fn record_str(&mut self, field: &Field, value: &str) {
        self.strings
            .insert(field.name().to_owned(), value.to_owned());
    }
    fn record_bool(&mut self, field: &Field, value: bool) {
        self.bools.insert(field.name().to_owned(), value);
    }
    fn record_u64(&mut self, field: &Field, value: u64) {
        self.numbers.insert(field.name().to_owned(), value as f64);
    }
    fn record_i64(&mut self, field: &Field, value: i64) {
        self.numbers.insert(field.name().to_owned(), value as f64);
    }
    fn record_f64(&mut self, field: &Field, value: f64) {
        self.numbers.insert(field.name().to_owned(), value);
    }
    fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
        // `%foo` and `?foo` arrive here. The runtime records `kind` and `dim`
        // with `%`, so a bridge that only implemented `record_str` would see an
        // empty catalogue and report nothing at all.
        self.strings.insert(
            field.name().to_owned(),
            format!("{value:?}").trim_matches('"').to_owned(),
        );
    }
}

// ── The plane under observation ─────────────────────────────────────────────

/// Performs one externally visible effect, so there is something to measure.
#[derive(Debug)]
struct Post;

#[async_trait::async_trait]
impl Skill for Post {
    fn descriptor(&self) -> SkillDescriptor {
        SkillDescriptor::new("ledger.post").provides("ledger.post")
    }

    async fn invoke(
        &self,
        cx: &mut StepCtx<'_>,
        _input: Tainted<Value>,
    ) -> Result<Outcome, SkillError> {
        // `Recorded` binds its payload as the value a sink would send, so it
        // goes through `cx.sink` with the *same* value the gate is shown. Pass
        // anything else and the argument binding refuses the call — correctly,
        // and the refusal now names the first differing JSON pointer.
        let arguments = Tainted::trusted(json!(null));
        let posted = cx.sink(Recorded::new("ledger.post"), &arguments).await?;
        Ok(Outcome::done(posted))
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let collected: Sink = Arc::default();

    // The whole bridge. In a real deployment `tracing_opentelemetry::layer()`
    // sits beside these two and does the exporting; what these two do is the
    // part it cannot do for you.
    tracing_subscriber::registry()
        .with(DropReplayedEffects(Arc::clone(&collected)))
        .with(MetricBridge(Arc::clone(&collected)))
        .init();

    let store = Arc::new(RedbStore::open_in_memory()?);
    let plane = Runtime::builder(Arc::clone(&store) as Arc<dyn JournalStore>)
        .cases(Arc::clone(&store) as Arc<dyn CaseStore>)
        .skill(Post)
        .build();

    // ── A live run: one effect that reached the world ───────────────────────
    let out = plane
        .run("ledger.post", Tainted::trusted(json!({})))
        .await?;
    assert_eq!(out.status, RunStatus::Succeeded);

    // ── The same run, replayed: the effect happens zero more times ──────────
    let replayed = plane.replay(out.run_id, Mode::Strict).await?;
    assert_eq!(replayed.status, RunStatus::Succeeded);

    {
        let seen = collected.lock().expect("collector");
        println!("1. latency series");
        println!("   live effect spans  : {:?}", seen.live_effects);
        println!("   replays, not timed : {}", seen.replayed_effects);
        assert_eq!(
            seen.live_effects.len(),
            1,
            "exactly one effect reached the world, and only it belongs in a \
             latency histogram"
        );
        assert!(
            seen.replayed_effects >= 1,
            "the replay must be visible as a replay: 'how much of this plane's \
             work is recovery' is a question an operator asks, and the answer \
             is not zero just because it is not latency"
        );
    }

    // ── 2. Gauges arrive with the sweep, and only with the sweep ────────────
    //
    // Scheduling the sweep *is* scheduling your gauges. Before the first tick a
    // plane has counters and no gauges, which reads on a dashboard exactly like
    // a plane holding nothing.
    {
        let seen = collected.lock().expect("collector");
        assert!(
            !seen
                .metrics
                .contains_key(agentplane::runtime::metrics::OPEN_CASES.name),
            "gauges must not appear before something queries the stores"
        );
    }

    #[allow(clippy::disallowed_methods)]
    let now = time::OffsetDateTime::now_utc();
    let report = plane
        .sweep(now, std::time::Duration::from_secs(3600))
        .await?;

    println!("\n2. the census, from the sweep");
    println!("   open cases   : {}", report.census.open_cases);
    println!("   open tasks   : {}", report.census.open_tasks);
    println!("   due deadlines: {}", report.census.due_deadlines);
    {
        let seen = collected.lock().expect("collector");
        assert!(
            seen.metrics
                .contains_key(agentplane::runtime::metrics::OPEN_CASES.name),
            "the sweep must emit the census, or a plane's gauges never exist"
        );
    }

    // ── 3. The alert predicate is already written ───────────────────────────
    //
    // One call, not a hand-rolled disjunction over counters: when the runtime
    // learns a new way for a tick to need a human, this is what learns it.
    println!("\n3. alerting");
    println!("   needs_attention(): {}", report.needs_attention());
    println!("   is_quiet()       : {}", report.is_quiet());
    if report.needs_attention() {
        // In production: `tracing::warn!(?report, ...)`, and the alert rule
        // keys on this event rather than on any single counter.
        println!("   → page somebody: {report:?}");
    }
    println!(
        "\n   census_unavailable is part of needs_attention() on purpose: gauges\n\
         \x20  that could not be read are a blind spot wearing a default, and\n\
         \x20  'zero open cases' and 'I could not count them' must not look alike."
    );

    println!("\n4. what a meter would hold");
    for (metric, series) in &collected.lock().expect("collector").metrics {
        for (dim, value) in series {
            println!("   {metric:<34} {dim:<12} {value}");
        }
    }

    Ok(())
}