Skip to main content

agentd/state/
mod.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The **durable state model**: entity kinds, the manifest, the write-ahead
3//! inbox, timers, the checkpoint policy and the restore protocol — one façade
4//! ([`Durable`]) over a [`crate::store::Store`] that the runtime is the single
5//! writer of.
6//!
7//! Every entity is a versioned [`Envelope`] under `<prefix>/<instance>/<kind>/<id>`;
8//! `Durable::put` allocates the next `seq` per key and treats a CAS conflict
9//! on a key it already owns as **fatal** (a second writer). The manifest indexes
10//! the live entities so a store without `list` can still be restored; it is
11//! flushed **debounced** (`store.checkpoint.debounce_ms`) and at drain.
12
13pub mod ulid;
14
15use crate::obs::log::Logger;
16use crate::store::{Envelope, KeySeq, PutOutcome, SharedStore, StoreError};
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19use std::collections::BTreeSet;
20use std::collections::{BTreeMap, HashMap};
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::sync::{Mutex, OnceLock};
23use std::time::{Duration, Instant};
24
25pub(crate) use crate::store::now_ms;
26
27/// The entity kinds a durable record can carry. The kind is a key segment, so
28/// adding one changes what a restore can enumerate by prefix.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
30pub enum Kind {
31    Manifest,
32    Inbox,
33    Context,
34    Run,
35    Subagent,
36    Task,
37    Memory,
38    Artifact,
39    Timer,
40    /// One event on a named stream, keyed `<stream>/e<seq>`.
41    /// Not manifest-indexed: streams keep their own head/tail counters in
42    /// [`Manifest::streams`], and events are walked by sequence, never listed.
43    Event,
44    Audit,
45    /// A cached endpoint credential: an OAuth/OIDC/AWS/SPIFFE access +
46    /// refresh token with its expiry, keyed by a hash of (endpoint, provider,
47    /// principal). Redaction-excluded — never logged, audited, or read-surfaced.
48    Cred,
49}
50
51impl Kind {
52    pub fn as_str(self) -> &'static str {
53        match self {
54            Kind::Manifest => "manifest",
55            Kind::Inbox => "inbox",
56            Kind::Context => "context",
57            Kind::Run => "run",
58            Kind::Subagent => "subagent",
59            Kind::Task => "task",
60            Kind::Memory => "memory",
61            Kind::Artifact => "artifact",
62            Kind::Timer => "timer",
63            Kind::Event => "event",
64            Kind::Audit => "audit",
65            Kind::Cred => "cred",
66        }
67    }
68    pub fn parse(s: &str) -> Option<Kind> {
69        Some(match s {
70            "manifest" => Kind::Manifest,
71            "inbox" => Kind::Inbox,
72            "event" => Kind::Event,
73            "context" => Kind::Context,
74            "run" => Kind::Run,
75            "subagent" => Kind::Subagent,
76            "task" => Kind::Task,
77            "memory" => Kind::Memory,
78            "artifact" => Kind::Artifact,
79            "timer" => Kind::Timer,
80            "audit" => Kind::Audit,
81            "cred" => Kind::Cred,
82            _ => return None,
83        })
84    }
85    /// Kinds the manifest indexes (restorable without `list`). Memory keys keep
86    /// their own index record; audit records are append-only history; cred records
87    /// are a self-keyed credential cache (not manifest-indexed).
88    pub fn indexed(self) -> bool {
89        !matches!(
90            self,
91            Kind::Manifest | Kind::Memory | Kind::Audit | Kind::Cred | Kind::Event
92        )
93    }
94}
95
96/// One live entity in the manifest index.
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct EntityRef {
99    pub kind: String,
100    pub id: String,
101    pub seq: u64,
102}
103
104/// A stream's durable counters.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
106pub struct StreamMeta {
107    /// Last appended sequence (0 = empty).
108    pub seq: u64,
109    /// Oldest retained sequence (seq+1 when empty after trims).
110    pub first: u64,
111}
112
113/// The instance manifest: the index of everything a restore must find, stored
114/// as one record under the `manifest` kind.
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
116pub struct Manifest {
117    #[serde(default)]
118    pub generation: u64,
119    #[serde(default)]
120    pub created: u64,
121    #[serde(default)]
122    pub updated: u64,
123    #[serde(default)]
124    pub entities: Vec<EntityRef>,
125    /// Start-node state per `<workflow>.<node>` (last fired, iteration, missed).
126    #[serde(default)]
127    pub starts: BTreeMap<String, Value>,
128    /// Per-stream head/tail: `seq` = last appended sequence, `first` = oldest
129    /// retained. Consumers walk `first..=seq` by key, so events never need a
130    /// `list` and a trimmed prefix simply falls out of the walk.
131    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
132    pub streams: BTreeMap<String, StreamMeta>,
133    /// Circuit-breaker state per `<workflow>/<step>` (`runtime::breaker`):
134    /// consecutive failures, open/closed, the probe claim. Durable because a
135    /// breaker that forgets on restart re-learns the outage by re-hammering
136    /// the dependency — the opposite of its job.
137    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
138    pub breakers: BTreeMap<String, Value>,
139    /// Budget counters per window/scope, so a spend limit survives a restart
140    /// rather than resetting the window every boot.
141    #[serde(default)]
142    pub budget: Value,
143    #[serde(default)]
144    pub lifecycle: Value,
145    /// The digest of the settings that shaped this state, section name -> hex.
146    /// A **signal, not a key**: a mismatch is reported at restore and the state
147    /// is resumed anyway. An empty map on either side skips the comparison
148    /// entirely, because an absent digest is not evidence that anything moved
149    /// and reporting one would drown the real signal in noise.
150    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
151    pub config_digest: BTreeMap<String, String>,
152    /// Records an earlier `--fresh` abandoned. They are still in the store —
153    /// `--fresh` deletes nothing — but they belong to a superseded
154    /// generation, so the `list` reconciliation in [`Durable::restore`] must not
155    /// re-adopt them and undo the flag one boot later.
156    #[serde(default, skip_serializing_if = "Vec::is_empty")]
157    pub retired: Vec<EntityRef>,
158}
159
160impl Manifest {
161    fn upsert(&mut self, kind: &str, id: &str, seq: u64) {
162        match self
163            .entities
164            .iter_mut()
165            .find(|e| e.kind == kind && e.id == id)
166        {
167            Some(e) => e.seq = seq,
168            None => self.entities.push(EntityRef {
169                kind: kind.to_string(),
170                id: id.to_string(),
171                seq,
172            }),
173        }
174    }
175    fn remove(&mut self, kind: &str, id: &str) {
176        self.entities.retain(|e| !(e.kind == kind && e.id == id));
177    }
178}
179
180/// A write-ahead inbox event: durably recorded before it is acted on, so a
181/// crash between arrival and handling replays it instead of dropping it.
182#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
183pub struct InboxEvent {
184    pub id: String,
185    pub kind: String,
186    pub ts: u64,
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub principal: Option<String>,
189    pub payload: Value,
190    #[serde(default)]
191    pub status: InboxStatus,
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
195#[serde(rename_all = "lowercase")]
196pub enum InboxStatus {
197    #[default]
198    Pending,
199    Done,
200}
201
202impl InboxEvent {
203    pub fn new(kind: &str, principal: Option<String>, payload: Value) -> InboxEvent {
204        InboxEvent {
205            id: ulid::new(),
206            kind: kind.to_string(),
207            ts: now_ms(),
208            principal,
209            payload,
210            status: InboxStatus::Pending,
211        }
212    }
213}
214
215/// A durable timer: an absolute deadline plus who owns it. The deadline is
216/// absolute rather than a remaining duration so that time spent down still
217/// counts, and a timer that came due while the process was gone fires at once.
218#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
219pub struct TimerRecord {
220    pub id: String,
221    pub deadline_ms: u64,
222    pub owner: Value,
223    #[serde(default)]
224    pub payload: Value,
225}
226
227/// The checkpoint policy knobs (`store.checkpoint`, `store.durability`,
228/// `store.on_error`).
229#[derive(Debug, Clone)]
230pub struct Policy {
231    pub debounce: Duration,
232    pub on_error: crate::config::v2::StoreOnError,
233    pub retries: u32,
234    /// Refuse a durable write whose serialized envelope exceeds this many
235    /// bytes (`store.max_value_bytes`; `None` = unbounded, the default).
236    ///
237    /// This exists because a store's WRITE limit and its READ limit need not
238    /// be the same number. An MCP-backed store reached through a broker can
239    /// accept a value on the way in and be unable to return it on the way
240    /// out — at which point the checkpoint is stranded: the write succeeded,
241    /// and the next BOOT RESTORE is what fails, when the agent is least able
242    /// to do anything about it. Refusing at write time keeps the failure where
243    /// an operator is looking and where `store.on_error` can act on it.
244    pub max_value_bytes: Option<u64>,
245}
246
247impl Default for Policy {
248    fn default() -> Self {
249        Policy {
250            debounce: Duration::from_millis(250),
251            on_error: crate::config::v2::StoreOnError::Halt,
252            retries: 3,
253            max_value_bytes: None,
254        }
255    }
256}
257
258impl Policy {
259    pub fn from_settings(s: &crate::config::v2::Store) -> Policy {
260        Policy {
261            debounce: Duration::from_millis(s.checkpoint.debounce_ms.unwrap_or(250)),
262            on_error: s.on_error,
263            retries: 3,
264            max_value_bytes: s.max_value_bytes,
265        }
266    }
267}
268
269// ---- startup intent ---------------------------------------------------------
270//
271// Two facts belong to *this process's life* rather than to the settings
272// document: "do not resume prior state" (`--fresh`) and "here is the
273// configuration we are about to run under" (the digest). Neither is a setting —
274// a file or an env var that pinned an instance to never resuming would be a
275// footgun, and the digest is derived, not authored — so neither has a document
276// path to bind to. The entry point knows both before the reactor exists; the
277// reactor reaches `restore()` holding only a store and a policy. Rather than
278// threading an argv fact through constructors that have no other reason to know
279// about argv, `main` records them here once, and `Durable::new` reads them.
280// Everything downstream works off the per-`Durable` copy, so a library embedder
281// (and every unit test) can set them explicitly instead.
282
283static FRESH: AtomicBool = AtomicBool::new(false);
284static CONFIG_DIGEST: OnceLock<BTreeMap<String, String>> = OnceLock::new();
285
286/// `--fresh` was given: the next [`Durable`] opened in this process starts a new
287/// generation instead of resuming.
288pub fn request_fresh() {
289    FRESH.store(true, Ordering::Relaxed);
290}
291
292/// Whether `--fresh` was given.
293pub fn fresh_requested() -> bool {
294    FRESH.load(Ordering::Relaxed)
295}
296
297/// Record the digest of the configuration this process runs under, for
298/// [`Durable::restore`] to compare against the manifest's.
299/// First call wins — the configuration is loaded once, before any side effect.
300pub fn record_config_digest(settings: &crate::config::v2::Settings) {
301    let _ = CONFIG_DIGEST.set(config_digest(settings));
302}
303
304fn recorded_config_digest() -> BTreeMap<String, String> {
305    CONFIG_DIGEST.get().cloned().unwrap_or_default()
306}
307
308/// The digest of the settings that **shaped the durable state**: section name →
309/// SHA-256 hex of that section's canonical JSON.
310///
311/// Deliberately *not* the whole document. Only the three sections whose meaning
312/// the stored records depend on are digested — a different `intelligence.model`
313/// or a new MCP server does not make yesterday's inbox mean something else, and
314/// including them would make the signal fire on every ordinary edit until an
315/// operator learned to ignore it.
316///
317/// Nothing secret-bearing goes in. `store.http.headers` and the endpoint URLs
318/// that can carry credentials in userinfo or a query are excluded by
319/// construction (see [`store_shape`]) — they are auth, not layout, and a digest
320/// of a low-entropy secret is a secret. The hash uses the crate's dependency-free
321/// SHA-256 ([`crate::sha::sha256_hex`], already the workflow/artifact content
322/// hash), so this adds no dependency and no feature gate.
323pub fn config_digest(settings: &crate::config::v2::Settings) -> BTreeMap<String, String> {
324    let mut out = BTreeMap::new();
325    // serde_json's Map is a BTreeMap here (no `preserve_order`), so `to_string`
326    // is already canonical: key order cannot make an unchanged config look moved.
327    let digest = |v: &Value| crate::sha::sha256_hex(v.to_string().as_bytes());
328    out.insert(
329        "workflows".to_string(),
330        digest(&Value::Array(settings.workflows.clone())),
331    );
332    out.insert("store".to_string(), digest(&store_shape(&settings.store)));
333    out.insert(
334        "limits".to_string(),
335        digest(&limits_shape(&settings.limits)),
336    );
337    out
338}
339
340/// The secret-free projection of `store` that shapes the state: where records
341/// go and how they are checkpointed. `Store` is deserialize-only, so this is
342/// spelled out field by field — which is the point: a new secret-bearing field
343/// cannot silently join the digest.
344fn store_shape(s: &crate::config::v2::Store) -> Value {
345    json!({
346        "kind": format!("{:?}", s.kind),
347        "prefix": s.prefix(),
348        "on_error": format!("{:?}", s.on_error),
349        "audit": s.audit,
350        "checkpoint_debounce_ms": s.checkpoint.debounce_ms,
351        "durability": format!("{:?}", s.durability),
352        "timeout_ms": s.timeout.map(|d| d.0.as_millis() as u64),
353        // The MCP server *name* is a config-local label, never a credential; the
354        // HTTP adapter contributes only its presence, because `base_url` and
355        // `headers` are auth surface rather than layout.
356        "mcp_server": s.mcp.as_ref().map(|m| m.server.clone()),
357        "http": s.http.is_some(),
358    })
359}
360
361/// The projection of `limits` — all numbers and durations, nothing secret. The
362/// resolved values (not the `Option`s) so that writing a default explicitly does
363/// not read as a change.
364fn limits_shape(s: &crate::config::v2::Limits) -> Value {
365    json!({
366        "max_runs": s.max_runs,
367        "run_steps": s.run.steps(),
368        "run_tokens": s.run.tokens(),
369        "run_deadline_ms": s.run.deadline().as_millis() as u64,
370        "subagents": format!("{:?}", s.subagents),
371        "inline_max_bytes": s.inline_max_bytes,
372        "step_timeout_ms": s.step_timeout.map(|d| d.0.as_millis() as u64),
373    })
374}
375
376/// Which digested sections moved between the manifest's record and this run.
377///
378/// An empty side never compares: a manifest may carry no digest at all, and a
379/// `Durable` built without settings (an embedder, a test) computes none —
380/// reporting "everything changed" in either case would train the operator to
381/// ignore the one event that matters.
382fn changed_sections(
383    recorded: &BTreeMap<String, String>,
384    current: &BTreeMap<String, String>,
385) -> Vec<String> {
386    if recorded.is_empty() || current.is_empty() {
387        return Vec::new();
388    }
389    let mut out: Vec<String> = current
390        .iter()
391        .filter(|(k, v)| recorded.get(*k) != Some(*v))
392        .map(|(k, _)| k.clone())
393        .collect();
394    out.extend(
395        recorded
396            .keys()
397            .filter(|k| !current.contains_key(*k))
398            .cloned(),
399    );
400    out.sort();
401    out.dedup();
402    out
403}
404
405/// The kinds the restore reconciles against `list` — the entity kinds a crash
406/// can leave in the store ahead of the manifest that indexes them.
407const RECONCILED: [Kind; 7] = [
408    Kind::Inbox,
409    Kind::Context,
410    Kind::Run,
411    Kind::Subagent,
412    Kind::Task,
413    Kind::Timer,
414    Kind::Artifact,
415];
416
417/// What a restore found.
418#[derive(Debug, Default)]
419pub struct Restored {
420    /// `None` ⇒ a fresh instance (no manifest).
421    pub manifest: Option<Manifest>,
422    /// Live entities by kind (tombstones excluded), each the latest envelope.
423    pub entities: BTreeMap<String, Vec<Envelope>>,
424    /// Indexed but missing from the store.
425    pub lost: Vec<EntityRef>,
426    /// Entities found by `list` that the manifest did not index (written after
427    /// the last flush — entity-first write order).
428    pub unindexed: Vec<EntityRef>,
429}
430
431impl Restored {
432    pub fn inbox_pending(&self) -> Vec<InboxEvent> {
433        let mut out: Vec<InboxEvent> = self
434            .entities
435            .get("inbox")
436            .map(|v| {
437                v.iter()
438                    .filter_map(|e| serde_json::from_value::<InboxEvent>(e.state.clone()).ok())
439                    .filter(|e| e.status == InboxStatus::Pending)
440                    .collect()
441            })
442            .unwrap_or_default();
443        out.sort_by(|a, b| a.ts.cmp(&b.ts).then(a.id.cmp(&b.id)));
444        out
445    }
446    pub fn timers(&self) -> Vec<TimerRecord> {
447        self.entities
448            .get("timer")
449            .map(|v| {
450                v.iter()
451                    .filter_map(|e| serde_json::from_value(e.state.clone()).ok())
452                    .collect()
453            })
454            .unwrap_or_default()
455    }
456    pub fn of(&self, kind: Kind) -> &[Envelope] {
457        self.entities
458            .get(kind.as_str())
459            .map(Vec::as_slice)
460            .unwrap_or(&[])
461    }
462    pub fn count(&self) -> usize {
463        self.entities.values().map(Vec::len).sum()
464    }
465}
466
467/// The durability façade: the single writer's view of the store.
468pub struct Durable {
469    store: SharedStore,
470    prefix: String,
471    instance: String,
472    policy: Policy,
473    /// Last known seq per key (warmed by restore; a key not here starts at 1
474    /// and adopts a stale record's seq once).
475    seqs: Mutex<HashMap<String, u64>>,
476    manifest: Mutex<Manifest>,
477    manifest_dirty: AtomicBool,
478    last_flush: Mutex<Instant>,
479    degraded: AtomicBool,
480    log: Option<Logger>,
481    /// `--fresh`: open a new generation instead of resuming.
482    fresh: bool,
483    /// The digest of the configuration this life runs under; empty when nothing
484    /// recorded one, which disables the comparison.
485    config_digest: BTreeMap<String, String>,
486}
487
488impl Durable {
489    pub fn new(
490        store: SharedStore,
491        prefix: &str,
492        instance: &str,
493        policy: Policy,
494        log: Option<Logger>,
495    ) -> Durable {
496        Durable {
497            store,
498            prefix: prefix.to_string(),
499            instance: instance.to_string(),
500            policy,
501            seqs: Mutex::new(HashMap::new()),
502            manifest: Mutex::new(Manifest::default()),
503            manifest_dirty: AtomicBool::new(false),
504            last_flush: Mutex::new(Instant::now()),
505            degraded: AtomicBool::new(false),
506            log,
507            fresh: fresh_requested(),
508            config_digest: recorded_config_digest(),
509        }
510    }
511
512    /// Override the `--fresh` intent this `Durable` was built with — for an
513    /// embedder that drives the façade directly, and for the tests, neither of
514    /// which goes through the CLI that sets the process-wide default.
515    pub fn with_fresh(mut self, fresh: bool) -> Durable {
516        self.fresh = fresh;
517        self
518    }
519
520    /// Override the configuration digest (see [`with_fresh`](Durable::with_fresh)).
521    pub fn with_config_digest(mut self, digest: BTreeMap<String, String>) -> Durable {
522        self.config_digest = digest;
523        self
524    }
525
526    pub fn store_kind(&self) -> &'static str {
527        self.store.kind()
528    }
529    pub fn instance(&self) -> &str {
530        &self.instance
531    }
532    pub fn prefix(&self) -> &str {
533        &self.prefix
534    }
535    pub fn key(&self, kind: Kind, id: &str) -> String {
536        crate::store::key(&self.prefix, &self.instance, kind.as_str(), id)
537    }
538    /// Whether the store has failed persistently and the policy chose to go on.
539    pub fn is_degraded(&self) -> bool {
540        self.degraded.load(Ordering::Relaxed)
541    }
542    pub fn policy(&self) -> &Policy {
543        &self.policy
544    }
545
546    // ---- entities -----------------------------------------------------------
547
548    /// Write an entity: allocates the next seq for its key, CAS-puts the
549    /// envelope, indexes it in the manifest (debounced flush). A conflict on a
550    /// key this instance already owns is fatal (`StoreError::Conflict`); on a
551    /// key first seen now, the stored seq is adopted once (a restore gap).
552    pub fn put(
553        &self,
554        kind: Kind,
555        id: &str,
556        state: Value,
557        hash: Option<String>,
558    ) -> Result<u64, StoreError> {
559        let key = self.key(kind, id);
560        // Bound the value BEFORE the CAS loop: measured once, on the state
561        // rather than the envelope, so the answer does not drift with a seq
562        // that grows by a digit. The envelope adds a small fixed header.
563        if let Some(cap) = self.policy.max_value_bytes {
564            let bytes = serde_json::to_vec(&state).map(|v| v.len()).unwrap_or(0) as u64;
565            if bytes > cap {
566                return Err(StoreError::TooLarge {
567                    key: key.clone(),
568                    bytes,
569                    cap,
570                });
571            }
572        }
573        let mut adopted = false;
574        let started = std::time::Instant::now();
575        loop {
576            let (seq, warmed) = {
577                let seqs = self.seqs.lock().unwrap_or_else(|e| e.into_inner());
578                match seqs.get(&key).copied() {
579                    Some(s) => (s + 1, true),
580                    None => (1, false),
581                }
582            };
583            let env = Envelope::new(
584                kind.as_str(),
585                id,
586                seq,
587                &self.instance,
588                hash.clone(),
589                state.clone(),
590            );
591            kill_point("state.before_put");
592            let outcome = crate::store::with_retry(
593                || self.store.put(&key, seq, &env.to_value()),
594                self.policy.retries,
595            );
596            match outcome {
597                Ok(PutOutcome::Ok) => {
598                    self.seqs
599                        .lock()
600                        .unwrap_or_else(|e| e.into_inner())
601                        .insert(key.clone(), seq);
602                    if kind.indexed() {
603                        let mut m = self.manifest.lock().unwrap_or_else(|e| e.into_inner());
604                        m.upsert(kind.as_str(), id, seq);
605                        m.updated = now_ms();
606                        self.manifest_dirty.store(true, Ordering::Relaxed);
607                    }
608                    self.degraded.store(false, Ordering::Relaxed);
609                    kill_point("state.after_put");
610                    crate::obs::metrics::record_store_op(
611                        "ok",
612                        started.elapsed().as_millis() as u64,
613                    );
614                    return Ok(seq);
615                }
616                Ok(PutOutcome::Conflict { latest_seq }) => {
617                    if !warmed && !adopted {
618                        // First touch of a key that already exists in the store
619                        // (a record written before a restore gap): adopt its
620                        // seq and retry once.
621                        if let Some(l) = latest_seq {
622                            self.seqs
623                                .lock()
624                                .unwrap_or_else(|e| e.into_inner())
625                                .insert(key.clone(), l);
626                            adopted = true;
627                            self.log_event("store.seq_adopted", json!({"key": key, "latest": l}));
628                            continue;
629                        }
630                    }
631                    self.log_event(
632                        "store.conflict",
633                        json!({"key": key, "seq": seq, "latest": latest_seq}),
634                    );
635                    crate::obs::metrics::record_store_op(
636                        "conflict",
637                        started.elapsed().as_millis() as u64,
638                    );
639                    return Err(StoreError::Conflict(format!(
640                        "key {key}: another writer owns it (our seq {seq}, latest {latest_seq:?})"
641                    )));
642                }
643                Err(e) => {
644                    self.log_event("store.put.fail", json!({"key": key, "err": e.to_string()}));
645                    crate::obs::metrics::record_store_op(
646                        "error",
647                        started.elapsed().as_millis() as u64,
648                    );
649                    if self.policy.on_error == crate::config::v2::StoreOnError::Degrade {
650                        self.degraded.store(true, Ordering::Relaxed);
651                        // Degraded: remember the seq we intended so a later put
652                        // does not reuse it, and go on.
653                        self.seqs
654                            .lock()
655                            .unwrap_or_else(|e| e.into_inner())
656                            .insert(key.clone(), seq);
657                        return Ok(seq);
658                    }
659                    return Err(e);
660                }
661            }
662        }
663    }
664
665    /// The latest envelope of an entity (tombstones read as absent).
666    pub fn get(&self, kind: Kind, id: &str) -> Result<Option<Envelope>, StoreError> {
667        let key = self.key(kind, id);
668        let v = crate::store::with_retry(|| self.store.get(&key, None), self.policy.retries)?;
669        match v {
670            None => Ok(None),
671            Some(v) => {
672                let env = Envelope::from_value(v)?;
673                self.seqs
674                    .lock()
675                    .unwrap_or_else(|e| e.into_inner())
676                    .insert(key, env.seq);
677                Ok(if env.is_tombstone() { None } else { Some(env) })
678            }
679        }
680    }
681
682    /// Remove an entity: `delete` when the store supports it, else a tombstone
683    /// (a `put` with `state: null`); drops it from the manifest index.
684    pub fn delete(&self, kind: Kind, id: &str) -> Result<(), StoreError> {
685        let key = self.key(kind, id);
686        match crate::store::with_retry(|| self.store.delete(&key), self.policy.retries) {
687            Ok(()) => {
688                self.seqs
689                    .lock()
690                    .unwrap_or_else(|e| e.into_inner())
691                    .remove(&key);
692            }
693            Err(StoreError::Unsupported(_)) => {
694                self.put(kind, id, Value::Null, None)?;
695            }
696            Err(e) => return Err(e),
697        }
698        if kind.indexed() {
699            let mut m = self.manifest.lock().unwrap_or_else(|e| e.into_inner());
700            m.remove(kind.as_str(), id);
701            m.updated = now_ms();
702            self.manifest_dirty.store(true, Ordering::Relaxed);
703        }
704        Ok(())
705    }
706
707    /// The store's `list` for a kind (optional).
708    pub fn list(&self, kind: Kind) -> Result<Vec<KeySeq>, StoreError> {
709        let prefix = format!("{}/{}/{}/", self.prefix, self.instance, kind.as_str());
710        self.store.list(&prefix)
711    }
712
713    // ---- inbox / timers -----------------------------------------------------
714
715    /// Write-ahead an event (before it is acted on / acknowledged).
716    pub fn inbox_put(&self, ev: &InboxEvent) -> Result<u64, StoreError> {
717        let seq = self.put(
718            Kind::Inbox,
719            &ev.id,
720            serde_json::to_value(ev).unwrap_or(Value::Null),
721            None,
722        )?;
723        kill_point("inbox.after_put");
724        Ok(seq)
725    }
726
727    /// Mark an event processed: deleted (or tombstoned) — it will not replay.
728    pub fn inbox_done(&self, id: &str) -> Result<(), StoreError> {
729        self.delete(Kind::Inbox, id)
730    }
731
732    pub fn timer_arm(&self, t: &TimerRecord) -> Result<u64, StoreError> {
733        self.put(
734            Kind::Timer,
735            &t.id,
736            serde_json::to_value(t).unwrap_or(Value::Null),
737            None,
738        )
739    }
740
741    pub fn timer_disarm(&self, id: &str) -> Result<(), StoreError> {
742        self.delete(Kind::Timer, id)
743    }
744
745    // ---- manifest -----------------------------------------------------------
746
747    pub fn manifest(&self) -> Manifest {
748        self.manifest
749            .lock()
750            .unwrap_or_else(|e| e.into_inner())
751            .clone()
752    }
753
754    /// Mutate the manifest (start-node state, budget counters, lifecycle) —
755    /// flushed debounced.
756    pub fn manifest_update(&self, f: impl FnOnce(&mut Manifest)) {
757        let mut m = self.manifest.lock().unwrap_or_else(|e| e.into_inner());
758        f(&mut m);
759        m.updated = now_ms();
760        self.manifest_dirty.store(true, Ordering::Relaxed);
761    }
762
763    /// Flush the manifest if dirty and (forced or the debounce elapsed).
764    pub fn flush(&self, force: bool) -> Result<bool, StoreError> {
765        if !self.manifest_dirty.load(Ordering::Relaxed) {
766            return Ok(false);
767        }
768        {
769            let last = self.last_flush.lock().unwrap_or_else(|e| e.into_inner());
770            if !force && last.elapsed() < self.policy.debounce {
771                return Ok(false);
772            }
773        }
774        let snapshot = self.manifest();
775        self.put(
776            Kind::Manifest,
777            "agent",
778            serde_json::to_value(&snapshot).unwrap_or(Value::Null),
779            None,
780        )?;
781        self.manifest_dirty.store(false, Ordering::Relaxed);
782        *self.last_flush.lock().unwrap_or_else(|e| e.into_inner()) = Instant::now();
783        Ok(true)
784    }
785
786    // ---- restore ------------------------------------------------------------
787
788    /// The restore protocol: read the manifest, then every indexed entity
789    /// (verifying envelopes), reconcile with `list` where supported, warm the
790    /// seq map, bump the generation. A fresh instance (no manifest) writes
791    /// generation 1.
792    ///
793    /// Under `--fresh` the middle is skipped: see
794    /// [`restore_fresh`](Durable::restore_fresh).
795    pub fn restore(&self) -> Result<Restored, StoreError> {
796        let mut out = Restored::default();
797        let (manifest, fresh) = match self.get(Kind::Manifest, "agent")? {
798            None => (
799                Manifest {
800                    generation: 0,
801                    created: now_ms(),
802                    updated: now_ms(),
803                    ..Manifest::default()
804                },
805                true,
806            ),
807            Some(env) => (
808                serde_json::from_value::<Manifest>(env.state.clone())
809                    .map_err(|e| StoreError::Corrupt(format!("manifest does not parse: {e}")))?,
810                false,
811            ),
812        };
813        // `--fresh` reads the manifest and stops there: the generation counter is
814        // the one thing a new life must inherit (otherwise "which life am I in?"
815        // resets on every use of the flag), and knowing what is being left behind
816        // is what lets the new generation retire it instead of deleting it.
817        if self.fresh {
818            return self.restore_fresh(manifest, fresh);
819        }
820        // The configuration digest is a **signal, not a gate**. Instance identity
821        // is `agent.name` alone: keying it on a config hash would start the agent
822        // fresh and orphan its in-flight workflows the first time someone raised
823        // a limit or fixed a typo — silently, which is exactly the outcome
824        // durability exists to prevent. So a difference is reported and the state
825        // is resumed regardless; the operator decides what to do about it.
826        let moved = changed_sections(&manifest.config_digest, &self.config_digest);
827        if !moved.is_empty() {
828            self.log_event(
829                "store.config_changed",
830                json!({
831                    "sections": moved,
832                    "msg": "state was written under a different configuration — resuming anyway; --fresh to start a new generation",
833                }),
834            );
835        }
836        // Indexed entities.
837        for r in &manifest.entities {
838            let Some(kind) = Kind::parse(&r.kind) else {
839                out.lost.push(r.clone());
840                continue;
841            };
842            match self.get(kind, &r.id)? {
843                Some(env) => out.entities.entry(r.kind.clone()).or_default().push(env),
844                None => out.lost.push(r.clone()),
845            }
846        }
847        // Reconcile with `list` (entity-first write order can leave records the
848        // manifest never indexed). `seen` doubles as the ground truth for
849        // pruning the retired set below; it stays `None` on a store without
850        // `list`, where "gone" and "invisible" cannot be told apart.
851        let mut seen: Option<BTreeSet<(String, String)>> = None;
852        for kind in RECONCILED {
853            match self.list(kind) {
854                Ok(keys) => {
855                    let seen = seen.get_or_insert_with(BTreeSet::new);
856                    for ks in keys {
857                        let Some((_, id)) =
858                            crate::store::parse_key(&self.prefix, &self.instance, &ks.key)
859                        else {
860                            continue;
861                        };
862                        seen.insert((kind.as_str().to_string(), id.to_string()));
863                        let indexed = manifest
864                            .entities
865                            .iter()
866                            .any(|e| e.kind == kind.as_str() && e.id == id);
867                        if indexed {
868                            continue;
869                        }
870                        // A record a previous `--fresh` retired: still on the
871                        // store because nothing was deleted, but it belongs to
872                        // an abandoned generation. Adopting it here would undo
873                        // the flag on the next ordinary start.
874                        if manifest
875                            .retired
876                            .iter()
877                            .any(|r| r.kind == kind.as_str() && r.id == id)
878                        {
879                            continue;
880                        }
881                        if let Some(env) = self.get(kind, id)? {
882                            out.unindexed.push(EntityRef {
883                                kind: kind.as_str().to_string(),
884                                id: id.to_string(),
885                                seq: env.seq,
886                            });
887                            out.entities
888                                .entry(kind.as_str().to_string())
889                                .or_default()
890                                .push(env);
891                        }
892                    }
893                }
894                Err(StoreError::Unsupported(_)) => {}
895                Err(e) => return Err(e),
896            }
897        }
898        // Adopt the manifest, re-index what we found, bump the generation. A
899        // fresh instance (no manifest) starts at generation 1 — but any records
900        // `list` found (a crash before the first flush) are adopted, not lost.
901        let mut m = manifest.clone();
902        m.entities.retain(|e| !out.lost.iter().any(|l| l == e));
903        for u in &out.unindexed {
904            m.upsert(&u.kind, &u.id, u.seq);
905        }
906        m.generation += 1;
907        m.updated = now_ms();
908        // Carry the digest of what we actually ran under, so the next life
909        // compares against this configuration rather than re-reporting the same
910        // move forever. An unrecorded digest leaves the manifest's alone — an
911        // embedder must not erase an operator's signal.
912        if !self.config_digest.is_empty() {
913            m.config_digest = self.config_digest.clone();
914        }
915        // Prune the retired set to what the store still holds: once an operator
916        // has cleaned out the abandoned generation, its ghost list should not be
917        // carried forever.
918        if let Some(seen) = &seen {
919            m.retired
920                .retain(|r| seen.contains(&(r.kind.clone(), r.id.clone())));
921        }
922        *self.manifest.lock().unwrap_or_else(|e| e.into_inner()) = m.clone();
923        self.manifest_dirty.store(true, Ordering::Relaxed);
924        self.flush(true)?;
925        if fresh && out.count() == 0 {
926            self.log_event("restore.fresh", json!({"generation": m.generation}));
927            return Ok(out);
928        }
929        self.log_event(
930            "restore.done",
931            json!({
932                "generation": m.generation,
933                "fresh_manifest": fresh,
934                "entities": out.count(),
935                "lost": out.lost.len(),
936                "unindexed": out.unindexed.len(),
937                "inbox_pending": out.inbox_pending().len(),
938            }),
939        );
940        out.manifest = Some(m);
941        Ok(out)
942    }
943
944    /// `--fresh`: open the NEXT generation without resuming.
945    ///
946    /// Nothing is unlinked. A flag that silently destroys durable state is a
947    /// footgun — the operator who types `--fresh` to get past a wedged run is
948    /// exactly the one who will want yesterday's conversation back — so the new
949    /// generation starts *alongside* the old one:
950    ///
951    /// * the outgoing manifest is copied to `manifest/agent.gen<N>`, because it
952    ///   is the index of the retired records and without it they are a heap of
953    ///   ULIDs no one can map back to anything;
954    /// * every record still in the store is named in the new manifest's
955    ///   `retired`, so the next ordinary start does not re-adopt them through the
956    ///   `list` reconciliation and quietly undo the flag one boot later;
957    /// * the generation counter is inherited and bumped, so the log says which
958    ///   life is live.
959    fn restore_fresh(
960        &self,
961        prior: Manifest,
962        no_prior_manifest: bool,
963    ) -> Result<Restored, StoreError> {
964        // What the new generation is walking away from: whatever `list` can see,
965        // plus the prior index (a store without `list` still has one).
966        let mut retired: Vec<EntityRef> = Vec::new();
967        let mut push = |kind: &str, id: &str, seq: u64| {
968            if !retired.iter().any(|r| r.kind == kind && r.id == id) {
969                retired.push(EntityRef {
970                    kind: kind.to_string(),
971                    id: id.to_string(),
972                    seq,
973                });
974            }
975        };
976        for kind in RECONCILED {
977            match self.list(kind) {
978                Ok(keys) => {
979                    for ks in keys {
980                        if let Some((_, id)) =
981                            crate::store::parse_key(&self.prefix, &self.instance, &ks.key)
982                        {
983                            push(kind.as_str(), id, ks.seq.unwrap_or(0));
984                        }
985                    }
986                }
987                Err(StoreError::Unsupported(_)) => {}
988                Err(e) => return Err(e),
989            }
990        }
991        for e in &prior.entities {
992            push(&e.kind, &e.id, e.seq);
993        }
994        for e in &prior.retired {
995            push(&e.kind, &e.id, e.seq);
996        }
997        // Preserve the outgoing index BEFORE overwriting `manifest/agent`: dying
998        // between the two writes then leaves a stray copy, never a lost one.
999        if !no_prior_manifest {
1000            self.put(
1001                Kind::Manifest,
1002                &format!("agent.gen{}", prior.generation),
1003                serde_json::to_value(&prior).unwrap_or(Value::Null),
1004                None,
1005            )?;
1006        }
1007        // Every field spelled out rather than `..prior`: the whole point of the
1008        // flag is that nothing carries over except the counter and the birth date.
1009        let m = Manifest {
1010            generation: prior.generation + 1,
1011            created: if prior.created == 0 {
1012                now_ms()
1013            } else {
1014                prior.created
1015            },
1016            updated: now_ms(),
1017            entities: Vec::new(),
1018            starts: BTreeMap::new(),
1019            streams: BTreeMap::new(),
1020            breakers: BTreeMap::new(),
1021            budget: Value::Null,
1022            lifecycle: Value::Null,
1023            config_digest: self.config_digest.clone(),
1024            retired,
1025        };
1026        *self.manifest.lock().unwrap_or_else(|e| e.into_inner()) = m.clone();
1027        self.manifest_dirty.store(true, Ordering::Relaxed);
1028        self.flush(true)?;
1029        self.log_event(
1030            "restore.fresh",
1031            json!({
1032                "generation": m.generation,
1033                "superseded": prior.generation,
1034                "retired": m.retired.len(),
1035                "msg": "--fresh: this generation starts empty; the previous one's records were kept, not deleted",
1036            }),
1037        );
1038        Ok(Restored::default())
1039    }
1040
1041    fn log_event(&self, event: &str, fields: Value) {
1042        if let Some(l) = &self.log {
1043            match event {
1044                e if e.ends_with(".fail")
1045                    || e == "store.conflict"
1046                    // A resumed state written under a different configuration is
1047                    // the operator's cue to check what moved — warn, not info.
1048                    || e == "store.config_changed" =>
1049                {
1050                    l.warn(event, fields)
1051                }
1052                _ => l.info(event, fields),
1053            }
1054        }
1055    }
1056}
1057
1058/// A test **kill point**: with `AGENTD_TEST_KILL_AT=<name>` set (debug /
1059/// `internal-mocks` builds only), the process SIGKILLs itself here — the chaos
1060/// suite's way of dying at an exact instant between two durable writes. It
1061/// compiles to nothing in a release build without `internal-mocks`, so a
1062/// production binary carries no self-kill path.
1063pub fn kill_point(name: &str) {
1064    #[cfg(any(feature = "internal-mocks", debug_assertions))]
1065    {
1066        if std::env::var("AGENTD_TEST_KILL_AT").as_deref() == Ok(name) {
1067            #[cfg(unix)]
1068            unsafe {
1069                libc::raise(libc::SIGKILL);
1070            }
1071            std::process::abort();
1072        }
1073    }
1074    #[cfg(not(any(feature = "internal-mocks", debug_assertions)))]
1075    {
1076        let _ = name;
1077    }
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082    use super::*;
1083    use crate::store::Store;
1084    use crate::store::memory::MemoryStore;
1085    use std::sync::Arc;
1086
1087    fn durable(store: Arc<MemoryStore>) -> Durable {
1088        Durable::new(
1089            store,
1090            "agentd",
1091            "inst",
1092            Policy {
1093                debounce: Duration::from_millis(0),
1094                ..Policy::default()
1095            },
1096            None,
1097        )
1098    }
1099
1100    #[test]
1101    fn put_allocates_seqs_indexes_and_flushes_manifest() {
1102        let mem = Arc::new(MemoryStore::new());
1103        let d = durable(mem.clone());
1104        assert!(d.restore().unwrap().manifest.is_none(), "fresh");
1105        assert_eq!(
1106            d.put(
1107                Kind::Run,
1108                "r1",
1109                json!({"status": "running"}),
1110                Some("h".into())
1111            )
1112            .unwrap(),
1113            1
1114        );
1115        assert_eq!(
1116            d.put(Kind::Run, "r1", json!({"status": "done"}), Some("h".into()))
1117                .unwrap(),
1118            2
1119        );
1120        assert_eq!(
1121            d.put(Kind::Context, "root", json!({"v": 1}), None).unwrap(),
1122            1
1123        );
1124        let env = d.get(Kind::Run, "r1").unwrap().unwrap();
1125        assert_eq!(env.seq, 2);
1126        assert_eq!(env.state["status"], json!("done"));
1127        assert_eq!(env.hash.as_deref(), Some("h"));
1128        // Manifest indexes both, flushed on demand.
1129        assert!(d.flush(true).unwrap());
1130        let m = d.manifest();
1131        assert_eq!(m.entities.len(), 2);
1132        assert!(
1133            m.entities
1134                .iter()
1135                .any(|e| e.kind == "run" && e.id == "r1" && e.seq == 2)
1136        );
1137        assert!(!d.flush(true).unwrap(), "clean after a flush");
1138        // delete removes + un-indexes.
1139        d.delete(Kind::Context, "root").unwrap();
1140        assert!(d.get(Kind::Context, "root").unwrap().is_none());
1141        assert_eq!(d.manifest().entities.len(), 1);
1142    }
1143
1144    #[test]
1145    fn conflicts_are_fatal_on_owned_keys_but_adopted_on_first_touch() {
1146        let mem = Arc::new(MemoryStore::new());
1147        // A record from a previous life the manifest never indexed.
1148        let stale = Envelope::new("run", "old", 5, "inst", None, json!({"x": 1}));
1149        mem.put("agentd/inst/run/old", 5, &stale.to_value())
1150            .unwrap();
1151        let d = durable(mem.clone());
1152        // First touch adopts seq 5 → writes 6.
1153        assert_eq!(d.put(Kind::Run, "old", json!({"x": 2}), None).unwrap(), 6);
1154        // A genuine second writer bumping the key behind our back is fatal.
1155        let other = Envelope::new("run", "old", 7, "other", None, json!({"x": 3}));
1156        mem.put("agentd/inst/run/old", 7, &other.to_value())
1157            .unwrap();
1158        assert!(matches!(
1159            d.put(Kind::Run, "old", json!({"x": 4}), None),
1160            Err(StoreError::Conflict(_))
1161        ));
1162    }
1163
1164    #[test]
1165    fn inbox_write_ahead_timers_and_restore() {
1166        let mem = Arc::new(MemoryStore::new());
1167        {
1168            let d = durable(mem.clone());
1169            d.restore().unwrap();
1170            let e1 = InboxEvent::new(
1171                "a2a_message",
1172                Some("user:andrii".into()),
1173                json!({"text": "hi"}),
1174            );
1175            let e2 = InboxEvent::new("start_fired", None, json!({"workflow": "w"}));
1176            d.inbox_put(&e1).unwrap();
1177            d.inbox_put(&e2).unwrap();
1178            d.inbox_done(&e1.id).unwrap();
1179            d.timer_arm(&TimerRecord {
1180                id: "t1".into(),
1181                deadline_ms: 42,
1182                owner: json!({"run": "r"}),
1183                payload: Value::Null,
1184            })
1185            .unwrap();
1186            d.put(
1187                Kind::Run,
1188                "r",
1189                json!({"status": "running"}),
1190                Some("hash".into()),
1191            )
1192            .unwrap();
1193            d.put(Kind::Task, "task-1", json!({"state": "working"}), None)
1194                .unwrap();
1195            d.manifest_update(|m| {
1196                m.starts.insert("w.s".into(), json!({"last_fired": 1}));
1197            });
1198            // A lost entity: indexed but gone from the store.
1199            d.put(Kind::Subagent, "gone", json!({}), None).unwrap();
1200            d.flush(true).unwrap();
1201            mem.delete("agentd/inst/subagent/gone").unwrap();
1202            // An entity written AFTER the last flush (entity-first order): not
1203            // indexed; the restore's `list` reconciliation finds it.
1204            d.put(Kind::Run, "r2", json!({"status": "running"}), None)
1205                .unwrap();
1206        }
1207        // "restart": a fresh Durable over the same store.
1208        let d2 = durable(mem.clone());
1209        let r = d2.restore().unwrap();
1210        let m = r.manifest.as_ref().unwrap();
1211        assert_eq!(m.generation, 2, "generation bumped");
1212        assert_eq!(m.starts["w.s"]["last_fired"], json!(1));
1213        let pending = r.inbox_pending();
1214        assert_eq!(pending.len(), 1, "the done event does not replay");
1215        assert_eq!(pending[0].kind, "start_fired");
1216        assert_eq!(r.timers().len(), 1);
1217        assert_eq!(r.timers()[0].deadline_ms, 42);
1218        assert_eq!(
1219            r.of(Kind::Run).len(),
1220            2,
1221            "indexed + unindexed runs restored"
1222        );
1223        assert!(r.unindexed.iter().any(|u| u.id == "r2"));
1224        assert!(
1225            r.lost
1226                .iter()
1227                .any(|l| l.kind == "subagent" && l.id == "gone")
1228        );
1229        assert_eq!(r.of(Kind::Task).len(), 1);
1230        // The seq map is warm: the next put of `r` continues the sequence.
1231        assert_eq!(
1232            d2.put(
1233                Kind::Run,
1234                "r",
1235                json!({"status": "done"}),
1236                Some("hash".into())
1237            )
1238            .unwrap(),
1239            2
1240        );
1241        // And the re-indexed manifest drops the lost entity.
1242        assert!(!d2.manifest().entities.iter().any(|e| e.id == "gone"));
1243    }
1244
1245    /// A value over `store.max_value_bytes` is refused AT WRITE TIME, and
1246    /// nothing is stored.
1247    ///
1248    /// The case this exists for: a store whose write limit exceeds its read
1249    /// limit — an MCP store reached through a broker typically caps a tool
1250    /// RESULT well below its request body. Writing such a value succeeds and
1251    /// strands the checkpoint; the failure then surfaces at the next BOOT
1252    /// RESTORE, when the agent is least able to do anything about it. Refusing
1253    /// here keeps it where `store.on_error` can act and an operator is looking.
1254    #[test]
1255    fn a_value_over_the_cap_is_refused_and_not_written() {
1256        let mem = Arc::new(MemoryStore::new());
1257        let d = Durable::new(
1258            mem.clone(),
1259            "agentd",
1260            "inst",
1261            Policy {
1262                debounce: Duration::from_millis(0),
1263                on_error: crate::config::v2::StoreOnError::Degrade,
1264                retries: 1,
1265                max_value_bytes: Some(512),
1266            },
1267            None,
1268        );
1269
1270        // Under the cap: written normally.
1271        let small = json!({"text": "x".repeat(100)});
1272        assert!(d.put(Kind::Run, "small", small, None).is_ok());
1273        assert!(d.get(Kind::Run, "small").unwrap().is_some());
1274
1275        // Over it: refused, naming the size and the cap.
1276        let big = json!({"text": "x".repeat(4096)});
1277        let err = d.put(Kind::Run, "big", big, None).unwrap_err();
1278        match &err {
1279            StoreError::TooLarge { key, bytes, cap } => {
1280                assert!(key.contains("big"), "the error names the key: {key}");
1281                assert!(*bytes > 4096 && *cap == 512, "{bytes} over {cap}");
1282            }
1283            other => panic!("expected TooLarge, got {other:?}"),
1284        }
1285        // The message tells an operator what to DO, not just what happened.
1286        let msg = err.to_string();
1287        assert!(
1288            msg.contains("NOT written") && msg.contains("restore"),
1289            "{msg}"
1290        );
1291        // And nothing was stored — a partial write here would be the same
1292        // stranded checkpoint by another route.
1293        assert!(d.get(Kind::Run, "big").unwrap().is_none());
1294    }
1295
1296    #[test]
1297    fn degrade_policy_keeps_going_and_flags_it() {
1298        let mem = Arc::new(MemoryStore::new());
1299        let d = Durable::new(
1300            mem.clone(),
1301            "agentd",
1302            "inst",
1303            Policy {
1304                debounce: Duration::from_millis(0),
1305                on_error: crate::config::v2::StoreOnError::Degrade,
1306                retries: 1,
1307                max_value_bytes: None,
1308            },
1309            None,
1310        );
1311        mem.fail_next(1);
1312        assert_eq!(
1313            d.put(Kind::Run, "r", json!({}), None).unwrap(),
1314            1,
1315            "degraded write reports the intended seq"
1316        );
1317        assert!(d.is_degraded());
1318        assert_eq!(
1319            d.put(Kind::Run, "r", json!({}), None).unwrap(),
1320            2,
1321            "seq not reused"
1322        );
1323        assert!(!d.is_degraded(), "a successful write clears the flag");
1324        // Halt policy surfaces the error.
1325        let d2 = durable(mem.clone());
1326        mem.fail_next(5);
1327        assert!(matches!(
1328            d2.put(Kind::Run, "x", json!({}), None),
1329            Err(StoreError::Io(_))
1330        ));
1331    }
1332
1333    /// `--fresh` opens the NEXT generation without resuming, and destroys
1334    /// nothing — the abandoned records stay readable, the outgoing index is
1335    /// preserved, and a later ordinary start does not quietly re-adopt them
1336    /// through the `list` reconciliation.
1337    #[test]
1338    fn fresh_opens_a_new_generation_without_resuming_and_deletes_nothing() {
1339        let mem = Arc::new(MemoryStore::new());
1340
1341        // Life 1: a run and a pending inbox event.
1342        let d = durable(mem.clone());
1343        assert!(d.restore().unwrap().manifest.is_none());
1344        d.put(Kind::Run, "r1", json!({"status": "running"}), None)
1345            .unwrap();
1346        let ev = InboxEvent::new("a2a.message", None, json!({"n": 1}));
1347        d.inbox_put(&ev).unwrap();
1348        assert_eq!(d.manifest().generation, 1);
1349
1350        // Life 2, `--fresh`: a new generation that resumes none of it.
1351        let f = durable(mem.clone()).with_fresh(true);
1352        let r = f.restore().unwrap();
1353        assert!(
1354            r.manifest.is_none(),
1355            "a new generation reports no prior life"
1356        );
1357        assert_eq!(r.count(), 0);
1358        assert!(r.inbox_pending().is_empty(), "the inbox does not replay");
1359        let m = f.manifest();
1360        assert_eq!(m.generation, 2, "the counter is inherited, not reset");
1361        assert!(m.entities.is_empty());
1362
1363        // Nothing was deleted, and the previous index is still findable.
1364        assert!(
1365            f.get(Kind::Run, "r1").unwrap().is_some(),
1366            "--fresh keeps the abandoned records"
1367        );
1368        let kept = f
1369            .get(Kind::Manifest, "agent.gen1")
1370            .unwrap()
1371            .expect("the outgoing manifest is preserved");
1372        let kept: Manifest = serde_json::from_value(kept.state).unwrap();
1373        assert_eq!(kept.generation, 1);
1374        assert!(m.retired.iter().any(|e| e.kind == "run" && e.id == "r1"));
1375        assert!(m.retired.iter().any(|e| e.kind == "inbox" && e.id == ev.id));
1376
1377        // Life 3, ordinary: the retired generation is not re-adopted — otherwise
1378        // the flag would come undone one boot later.
1379        let d3 = durable(mem.clone());
1380        let r3 = d3.restore().unwrap();
1381        assert_eq!(r3.count(), 0, "retired records stay retired");
1382        assert!(r3.unindexed.is_empty());
1383        assert_eq!(d3.manifest().generation, 3);
1384    }
1385
1386    /// The configuration digest is a **signal, not a key** — a difference is
1387    /// reported and the state is resumed anyway, because keying instance
1388    /// identity on a config hash would orphan a live workflow on a typo fix.
1389    #[test]
1390    fn a_moved_config_digest_reports_but_never_gates_the_resume() {
1391        let before: BTreeMap<String, String> = [
1392            ("workflows".to_string(), "aaa".to_string()),
1393            ("store".to_string(), "sss".to_string()),
1394        ]
1395        .into_iter()
1396        .collect();
1397        let mut after = before.clone();
1398        after.insert("workflows".to_string(), "bbb".to_string());
1399        assert_eq!(changed_sections(&before, &after), vec!["workflows"]);
1400        // An empty side never compares: a manifest carrying no digest, or a
1401        // `Durable` built without settings, must not announce that all moved.
1402        assert!(changed_sections(&BTreeMap::new(), &after).is_empty());
1403        assert!(changed_sections(&before, &BTreeMap::new()).is_empty());
1404
1405        let mem = Arc::new(MemoryStore::new());
1406        let d = durable(mem.clone()).with_config_digest(before.clone());
1407        d.restore().unwrap();
1408        d.put(Kind::Run, "r1", json!({"status": "running"}), None)
1409            .unwrap();
1410        assert_eq!(d.manifest().config_digest, before);
1411
1412        let d2 = durable(mem.clone()).with_config_digest(after.clone());
1413        let r = d2.restore().unwrap();
1414        assert_eq!(r.of(Kind::Run).len(), 1, "state is still resumed");
1415        assert_eq!(d2.manifest().generation, 2);
1416        assert_eq!(
1417            d2.manifest().config_digest,
1418            after,
1419            "the next life compares against what this one ran under"
1420        );
1421    }
1422
1423    /// The digest covers only the sections whose meaning the stored records
1424    /// depend on — an edit anywhere else must not fire it.
1425    #[test]
1426    fn the_digest_covers_workflows_store_and_limits_only() {
1427        let doc = json!({
1428            "config_version": "1",
1429            "agent": {"name": "a", "instruction": "one"},
1430            "workflows": [{"name": "w", "version": 3, "steps": {"s": {"kind": "once"}}}],
1431            "limits": {"run": {"steps": 10}},
1432        });
1433        let settings = |patch: &dyn Fn(&mut Value)| {
1434            let mut d = doc.clone();
1435            patch(&mut d);
1436            serde_json::from_value::<crate::config::v2::Settings>(d).expect("settings")
1437        };
1438        let base = config_digest(&settings(&|_| {}));
1439        assert_eq!(
1440            base.keys().collect::<Vec<_>>(),
1441            ["limits", "store", "workflows"]
1442        );
1443
1444        let elsewhere = config_digest(&settings(&|d| d["agent"]["instruction"] = json!("two")));
1445        assert_eq!(base, elsewhere, "an instruction edit is not a state change");
1446
1447        let wf = config_digest(&settings(&|d| {
1448            d["workflows"][0]["steps"]["t"] = json!({"kind": "noop"})
1449        }));
1450        assert_eq!(changed_sections(&base, &wf), vec!["workflows"]);
1451
1452        let lim = config_digest(&settings(&|d| d["limits"]["run"]["steps"] = json!(11)));
1453        assert_eq!(changed_sections(&base, &lim), vec!["limits"]);
1454    }
1455}