Skip to main content

agentd/state/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The **durable state model** (RFC 0025 §3, §5–§7): entity kinds, the
3//! manifest, the write-ahead inbox, timers, the checkpoint policy and the
4//! restore protocol — one façade ([`Durable`]) over a [`crate::store::Store`]
5//! that the runtime (RFC 0026) is the single 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::{BTreeMap, HashMap};
20use std::sync::Mutex;
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::time::{Duration, Instant};
23
24pub(crate) use crate::store::now_ms;
25
26/// The entity kinds (RFC 0025 §3.3).
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
28pub enum Kind {
29    Manifest,
30    Inbox,
31    Context,
32    Run,
33    Subagent,
34    Task,
35    Memory,
36    Artifact,
37    Timer,
38    Audit,
39    /// A cached endpoint credential (RFC 0031): an OAuth/OIDC/AWS/SPIFFE access +
40    /// refresh token with its expiry, keyed by a hash of (endpoint, provider,
41    /// principal). Redaction-excluded — never logged, audited, or read-surfaced.
42    Cred,
43}
44
45impl Kind {
46    pub fn as_str(self) -> &'static str {
47        match self {
48            Kind::Manifest => "manifest",
49            Kind::Inbox => "inbox",
50            Kind::Context => "context",
51            Kind::Run => "run",
52            Kind::Subagent => "subagent",
53            Kind::Task => "task",
54            Kind::Memory => "memory",
55            Kind::Artifact => "artifact",
56            Kind::Timer => "timer",
57            Kind::Audit => "audit",
58            Kind::Cred => "cred",
59        }
60    }
61    pub fn parse(s: &str) -> Option<Kind> {
62        Some(match s {
63            "manifest" => Kind::Manifest,
64            "inbox" => Kind::Inbox,
65            "context" => Kind::Context,
66            "run" => Kind::Run,
67            "subagent" => Kind::Subagent,
68            "task" => Kind::Task,
69            "memory" => Kind::Memory,
70            "artifact" => Kind::Artifact,
71            "timer" => Kind::Timer,
72            "audit" => Kind::Audit,
73            "cred" => Kind::Cred,
74            _ => return None,
75        })
76    }
77    /// Kinds the manifest indexes (restorable without `list`). Memory keys keep
78    /// their own index record; audit records are append-only history; cred records
79    /// are a self-keyed credential cache (not manifest-indexed).
80    pub fn indexed(self) -> bool {
81        !matches!(
82            self,
83            Kind::Manifest | Kind::Memory | Kind::Audit | Kind::Cred
84        )
85    }
86}
87
88/// One live entity in the manifest index.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct EntityRef {
91    pub kind: String,
92    pub id: String,
93    pub seq: u64,
94}
95
96/// The instance manifest (RFC 0025 §3.3 `manifest`).
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
98pub struct Manifest {
99    #[serde(default)]
100    pub generation: u64,
101    #[serde(default)]
102    pub created: u64,
103    #[serde(default)]
104    pub updated: u64,
105    #[serde(default)]
106    pub entities: Vec<EntityRef>,
107    /// Start-node state per `<workflow>.<node>` (last fired, iteration, missed).
108    #[serde(default)]
109    pub starts: BTreeMap<String, Value>,
110    /// Budget counters per window/scope (RFC 0026 §7).
111    #[serde(default)]
112    pub budget: Value,
113    #[serde(default)]
114    pub lifecycle: Value,
115}
116
117impl Manifest {
118    fn upsert(&mut self, kind: &str, id: &str, seq: u64) {
119        match self
120            .entities
121            .iter_mut()
122            .find(|e| e.kind == kind && e.id == id)
123        {
124            Some(e) => e.seq = seq,
125            None => self.entities.push(EntityRef {
126                kind: kind.to_string(),
127                id: id.to_string(),
128                seq,
129            }),
130        }
131    }
132    fn remove(&mut self, kind: &str, id: &str) {
133        self.entities.retain(|e| !(e.kind == kind && e.id == id));
134    }
135}
136
137/// A write-ahead inbox event (RFC 0025 §5).
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139pub struct InboxEvent {
140    pub id: String,
141    pub kind: String,
142    pub ts: u64,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub principal: Option<String>,
145    pub payload: Value,
146    #[serde(default)]
147    pub status: InboxStatus,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
151#[serde(rename_all = "lowercase")]
152pub enum InboxStatus {
153    #[default]
154    Pending,
155    Done,
156}
157
158impl InboxEvent {
159    pub fn new(kind: &str, principal: Option<String>, payload: Value) -> InboxEvent {
160        InboxEvent {
161            id: ulid::new(),
162            kind: kind.to_string(),
163            ts: now_ms(),
164            principal,
165            payload,
166            status: InboxStatus::Pending,
167        }
168    }
169}
170
171/// A durable timer (RFC 0025 §3.3 `timer`): an absolute deadline + who owns it.
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173pub struct TimerRecord {
174    pub id: String,
175    pub deadline_ms: u64,
176    pub owner: Value,
177    #[serde(default)]
178    pub payload: Value,
179}
180
181/// The checkpoint policy knobs (`store.checkpoint`, `store.durability`,
182/// `store.on_error`).
183#[derive(Debug, Clone)]
184pub struct Policy {
185    pub debounce: Duration,
186    pub on_error: crate::config::v2::StoreOnError,
187    pub retries: u32,
188}
189
190impl Default for Policy {
191    fn default() -> Self {
192        Policy {
193            debounce: Duration::from_millis(250),
194            on_error: crate::config::v2::StoreOnError::Halt,
195            retries: 3,
196        }
197    }
198}
199
200impl Policy {
201    pub fn from_settings(s: &crate::config::v2::Store) -> Policy {
202        Policy {
203            debounce: Duration::from_millis(s.checkpoint.debounce_ms.unwrap_or(250)),
204            on_error: s.on_error,
205            retries: 3,
206        }
207    }
208}
209
210/// What a restore found (RFC 0025 §6).
211#[derive(Debug, Default)]
212pub struct Restored {
213    /// `None` ⇒ a fresh instance (no manifest).
214    pub manifest: Option<Manifest>,
215    /// Live entities by kind (tombstones excluded), each the latest envelope.
216    pub entities: BTreeMap<String, Vec<Envelope>>,
217    /// Indexed but missing from the store.
218    pub lost: Vec<EntityRef>,
219    /// Entities found by `list` that the manifest did not index (written after
220    /// the last flush — entity-first write order).
221    pub unindexed: Vec<EntityRef>,
222}
223
224impl Restored {
225    pub fn inbox_pending(&self) -> Vec<InboxEvent> {
226        let mut out: Vec<InboxEvent> = self
227            .entities
228            .get("inbox")
229            .map(|v| {
230                v.iter()
231                    .filter_map(|e| serde_json::from_value::<InboxEvent>(e.state.clone()).ok())
232                    .filter(|e| e.status == InboxStatus::Pending)
233                    .collect()
234            })
235            .unwrap_or_default();
236        out.sort_by(|a, b| a.ts.cmp(&b.ts).then(a.id.cmp(&b.id)));
237        out
238    }
239    pub fn timers(&self) -> Vec<TimerRecord> {
240        self.entities
241            .get("timer")
242            .map(|v| {
243                v.iter()
244                    .filter_map(|e| serde_json::from_value(e.state.clone()).ok())
245                    .collect()
246            })
247            .unwrap_or_default()
248    }
249    pub fn of(&self, kind: Kind) -> &[Envelope] {
250        self.entities
251            .get(kind.as_str())
252            .map(Vec::as_slice)
253            .unwrap_or(&[])
254    }
255    pub fn count(&self) -> usize {
256        self.entities.values().map(Vec::len).sum()
257    }
258}
259
260/// The durability façade: the single writer's view of the store.
261pub struct Durable {
262    store: SharedStore,
263    prefix: String,
264    instance: String,
265    policy: Policy,
266    /// Last known seq per key (warmed by restore; a key not here starts at 1
267    /// and adopts a stale record's seq once).
268    seqs: Mutex<HashMap<String, u64>>,
269    manifest: Mutex<Manifest>,
270    manifest_dirty: AtomicBool,
271    last_flush: Mutex<Instant>,
272    degraded: AtomicBool,
273    log: Option<Logger>,
274}
275
276impl Durable {
277    pub fn new(
278        store: SharedStore,
279        prefix: &str,
280        instance: &str,
281        policy: Policy,
282        log: Option<Logger>,
283    ) -> Durable {
284        Durable {
285            store,
286            prefix: prefix.to_string(),
287            instance: instance.to_string(),
288            policy,
289            seqs: Mutex::new(HashMap::new()),
290            manifest: Mutex::new(Manifest::default()),
291            manifest_dirty: AtomicBool::new(false),
292            last_flush: Mutex::new(Instant::now()),
293            degraded: AtomicBool::new(false),
294            log,
295        }
296    }
297
298    pub fn store_kind(&self) -> &'static str {
299        self.store.kind()
300    }
301    pub fn instance(&self) -> &str {
302        &self.instance
303    }
304    pub fn prefix(&self) -> &str {
305        &self.prefix
306    }
307    pub fn key(&self, kind: Kind, id: &str) -> String {
308        crate::store::key(&self.prefix, &self.instance, kind.as_str(), id)
309    }
310    /// Whether the store has failed persistently and the policy chose to go on.
311    pub fn is_degraded(&self) -> bool {
312        self.degraded.load(Ordering::Relaxed)
313    }
314    pub fn policy(&self) -> &Policy {
315        &self.policy
316    }
317
318    // ---- entities -----------------------------------------------------------
319
320    /// Write an entity: allocates the next seq for its key, CAS-puts the
321    /// envelope, indexes it in the manifest (debounced flush). A conflict on a
322    /// key this instance already owns is fatal (`StoreError::Conflict`); on a
323    /// key first seen now, the stored seq is adopted once (a restore gap).
324    pub fn put(
325        &self,
326        kind: Kind,
327        id: &str,
328        state: Value,
329        hash: Option<String>,
330    ) -> Result<u64, StoreError> {
331        let key = self.key(kind, id);
332        let mut adopted = false;
333        let started = std::time::Instant::now();
334        loop {
335            let (seq, warmed) = {
336                let seqs = self.seqs.lock().unwrap_or_else(|e| e.into_inner());
337                match seqs.get(&key).copied() {
338                    Some(s) => (s + 1, true),
339                    None => (1, false),
340                }
341            };
342            let env = Envelope::new(
343                kind.as_str(),
344                id,
345                seq,
346                &self.instance,
347                hash.clone(),
348                state.clone(),
349            );
350            kill_point("state.before_put");
351            let outcome = crate::store::with_retry(
352                || self.store.put(&key, seq, &env.to_value()),
353                self.policy.retries,
354            );
355            match outcome {
356                Ok(PutOutcome::Ok) => {
357                    self.seqs
358                        .lock()
359                        .unwrap_or_else(|e| e.into_inner())
360                        .insert(key.clone(), seq);
361                    if kind.indexed() {
362                        let mut m = self.manifest.lock().unwrap_or_else(|e| e.into_inner());
363                        m.upsert(kind.as_str(), id, seq);
364                        m.updated = now_ms();
365                        self.manifest_dirty.store(true, Ordering::Relaxed);
366                    }
367                    self.degraded.store(false, Ordering::Relaxed);
368                    kill_point("state.after_put");
369                    crate::obs::metrics::record_store_op(
370                        "ok",
371                        started.elapsed().as_millis() as u64,
372                    );
373                    return Ok(seq);
374                }
375                Ok(PutOutcome::Conflict { latest_seq }) => {
376                    if !warmed && !adopted {
377                        // First touch of a key that already exists in the store
378                        // (a record written before a restore gap): adopt its
379                        // seq and retry once.
380                        if let Some(l) = latest_seq {
381                            self.seqs
382                                .lock()
383                                .unwrap_or_else(|e| e.into_inner())
384                                .insert(key.clone(), l);
385                            adopted = true;
386                            self.log_event("store.seq_adopted", json!({"key": key, "latest": l}));
387                            continue;
388                        }
389                    }
390                    self.log_event(
391                        "store.conflict",
392                        json!({"key": key, "seq": seq, "latest": latest_seq}),
393                    );
394                    crate::obs::metrics::record_store_op(
395                        "conflict",
396                        started.elapsed().as_millis() as u64,
397                    );
398                    return Err(StoreError::Conflict(format!(
399                        "key {key}: another writer owns it (our seq {seq}, latest {latest_seq:?})"
400                    )));
401                }
402                Err(e) => {
403                    self.log_event("store.put.fail", json!({"key": key, "err": e.to_string()}));
404                    crate::obs::metrics::record_store_op(
405                        "error",
406                        started.elapsed().as_millis() as u64,
407                    );
408                    if self.policy.on_error == crate::config::v2::StoreOnError::Degrade {
409                        self.degraded.store(true, Ordering::Relaxed);
410                        // Degraded: remember the seq we intended so a later put
411                        // does not reuse it, and go on.
412                        self.seqs
413                            .lock()
414                            .unwrap_or_else(|e| e.into_inner())
415                            .insert(key.clone(), seq);
416                        return Ok(seq);
417                    }
418                    return Err(e);
419                }
420            }
421        }
422    }
423
424    /// The latest envelope of an entity (tombstones read as absent).
425    pub fn get(&self, kind: Kind, id: &str) -> Result<Option<Envelope>, StoreError> {
426        let key = self.key(kind, id);
427        let v = crate::store::with_retry(|| self.store.get(&key, None), self.policy.retries)?;
428        match v {
429            None => Ok(None),
430            Some(v) => {
431                let env = Envelope::from_value(v)?;
432                self.seqs
433                    .lock()
434                    .unwrap_or_else(|e| e.into_inner())
435                    .insert(key, env.seq);
436                Ok(if env.is_tombstone() { None } else { Some(env) })
437            }
438        }
439    }
440
441    /// Remove an entity: `delete` when the store supports it, else a tombstone
442    /// (a `put` with `state: null`); drops it from the manifest index.
443    pub fn delete(&self, kind: Kind, id: &str) -> Result<(), StoreError> {
444        let key = self.key(kind, id);
445        match crate::store::with_retry(|| self.store.delete(&key), self.policy.retries) {
446            Ok(()) => {
447                self.seqs
448                    .lock()
449                    .unwrap_or_else(|e| e.into_inner())
450                    .remove(&key);
451            }
452            Err(StoreError::Unsupported(_)) => {
453                self.put(kind, id, Value::Null, None)?;
454            }
455            Err(e) => return Err(e),
456        }
457        if kind.indexed() {
458            let mut m = self.manifest.lock().unwrap_or_else(|e| e.into_inner());
459            m.remove(kind.as_str(), id);
460            m.updated = now_ms();
461            self.manifest_dirty.store(true, Ordering::Relaxed);
462        }
463        Ok(())
464    }
465
466    /// The store's `list` for a kind (optional).
467    pub fn list(&self, kind: Kind) -> Result<Vec<KeySeq>, StoreError> {
468        let prefix = format!("{}/{}/{}/", self.prefix, self.instance, kind.as_str());
469        self.store.list(&prefix)
470    }
471
472    // ---- inbox / timers -----------------------------------------------------
473
474    /// Write-ahead an event (before it is acted on / acknowledged).
475    pub fn inbox_put(&self, ev: &InboxEvent) -> Result<u64, StoreError> {
476        let seq = self.put(
477            Kind::Inbox,
478            &ev.id,
479            serde_json::to_value(ev).unwrap_or(Value::Null),
480            None,
481        )?;
482        kill_point("inbox.after_put");
483        Ok(seq)
484    }
485
486    /// Mark an event processed: deleted (or tombstoned) — it will not replay.
487    pub fn inbox_done(&self, id: &str) -> Result<(), StoreError> {
488        self.delete(Kind::Inbox, id)
489    }
490
491    pub fn timer_arm(&self, t: &TimerRecord) -> Result<u64, StoreError> {
492        self.put(
493            Kind::Timer,
494            &t.id,
495            serde_json::to_value(t).unwrap_or(Value::Null),
496            None,
497        )
498    }
499
500    pub fn timer_disarm(&self, id: &str) -> Result<(), StoreError> {
501        self.delete(Kind::Timer, id)
502    }
503
504    // ---- manifest -----------------------------------------------------------
505
506    pub fn manifest(&self) -> Manifest {
507        self.manifest
508            .lock()
509            .unwrap_or_else(|e| e.into_inner())
510            .clone()
511    }
512
513    /// Mutate the manifest (start-node state, budget counters, lifecycle) —
514    /// flushed debounced.
515    pub fn manifest_update(&self, f: impl FnOnce(&mut Manifest)) {
516        let mut m = self.manifest.lock().unwrap_or_else(|e| e.into_inner());
517        f(&mut m);
518        m.updated = now_ms();
519        self.manifest_dirty.store(true, Ordering::Relaxed);
520    }
521
522    /// Flush the manifest if dirty and (forced or the debounce elapsed).
523    pub fn flush(&self, force: bool) -> Result<bool, StoreError> {
524        if !self.manifest_dirty.load(Ordering::Relaxed) {
525            return Ok(false);
526        }
527        {
528            let last = self.last_flush.lock().unwrap_or_else(|e| e.into_inner());
529            if !force && last.elapsed() < self.policy.debounce {
530                return Ok(false);
531            }
532        }
533        let snapshot = self.manifest();
534        self.put(
535            Kind::Manifest,
536            "agent",
537            serde_json::to_value(&snapshot).unwrap_or(Value::Null),
538            None,
539        )?;
540        self.manifest_dirty.store(false, Ordering::Relaxed);
541        *self.last_flush.lock().unwrap_or_else(|e| e.into_inner()) = Instant::now();
542        Ok(true)
543    }
544
545    // ---- restore ------------------------------------------------------------
546
547    /// The restore protocol (RFC 0025 §6): read the manifest, then every indexed
548    /// entity (verifying envelopes), reconcile with `list` where supported, warm
549    /// the seq map, bump the generation. A fresh instance (no manifest) writes
550    /// generation 1.
551    pub fn restore(&self) -> Result<Restored, StoreError> {
552        let mut out = Restored::default();
553        let (manifest, fresh) = match self.get(Kind::Manifest, "agent")? {
554            None => (
555                Manifest {
556                    generation: 0,
557                    created: now_ms(),
558                    updated: now_ms(),
559                    ..Manifest::default()
560                },
561                true,
562            ),
563            Some(env) => (
564                serde_json::from_value::<Manifest>(env.state.clone())
565                    .map_err(|e| StoreError::Corrupt(format!("manifest does not parse: {e}")))?,
566                false,
567            ),
568        };
569        // Indexed entities.
570        for r in &manifest.entities {
571            let Some(kind) = Kind::parse(&r.kind) else {
572                out.lost.push(r.clone());
573                continue;
574            };
575            match self.get(kind, &r.id)? {
576                Some(env) => out.entities.entry(r.kind.clone()).or_default().push(env),
577                None => out.lost.push(r.clone()),
578            }
579        }
580        // Reconcile with `list` (entity-first write order can leave records the
581        // manifest never indexed).
582        for kind in [
583            Kind::Inbox,
584            Kind::Context,
585            Kind::Run,
586            Kind::Subagent,
587            Kind::Task,
588            Kind::Timer,
589            Kind::Artifact,
590        ] {
591            match self.list(kind) {
592                Ok(keys) => {
593                    for ks in keys {
594                        let Some((_, id)) =
595                            crate::store::parse_key(&self.prefix, &self.instance, &ks.key)
596                        else {
597                            continue;
598                        };
599                        let indexed = manifest
600                            .entities
601                            .iter()
602                            .any(|e| e.kind == kind.as_str() && e.id == id);
603                        if indexed {
604                            continue;
605                        }
606                        if let Some(env) = self.get(kind, id)? {
607                            out.unindexed.push(EntityRef {
608                                kind: kind.as_str().to_string(),
609                                id: id.to_string(),
610                                seq: env.seq,
611                            });
612                            out.entities
613                                .entry(kind.as_str().to_string())
614                                .or_default()
615                                .push(env);
616                        }
617                    }
618                }
619                Err(StoreError::Unsupported(_)) => {}
620                Err(e) => return Err(e),
621            }
622        }
623        // Adopt the manifest, re-index what we found, bump the generation. A
624        // fresh instance (no manifest) starts at generation 1 — but any records
625        // `list` found (a crash before the first flush) are adopted, not lost.
626        let mut m = manifest.clone();
627        m.entities.retain(|e| !out.lost.iter().any(|l| l == e));
628        for u in &out.unindexed {
629            m.upsert(&u.kind, &u.id, u.seq);
630        }
631        m.generation += 1;
632        m.updated = now_ms();
633        *self.manifest.lock().unwrap_or_else(|e| e.into_inner()) = m.clone();
634        self.manifest_dirty.store(true, Ordering::Relaxed);
635        self.flush(true)?;
636        if fresh && out.count() == 0 {
637            self.log_event("restore.fresh", json!({"generation": 1}));
638            return Ok(out);
639        }
640        self.log_event(
641            "restore.done",
642            json!({
643                "generation": m.generation,
644                "fresh_manifest": fresh,
645                "entities": out.count(),
646                "lost": out.lost.len(),
647                "unindexed": out.unindexed.len(),
648                "inbox_pending": out.inbox_pending().len(),
649            }),
650        );
651        out.manifest = Some(m);
652        Ok(out)
653    }
654
655    fn log_event(&self, event: &str, fields: Value) {
656        if let Some(l) = &self.log {
657            match event {
658                e if e.ends_with(".fail") || e == "store.conflict" => l.warn(event, fields),
659                _ => l.info(event, fields),
660            }
661        }
662    }
663}
664
665/// A test **kill point** (RFC test strategy §2): with `AGENTD_TEST_KILL_AT=<name>`
666/// set (debug / `internal-mocks` builds only), the process SIGKILLs itself
667/// here — the chaos suite's way of dying between two durable writes.
668pub fn kill_point(name: &str) {
669    #[cfg(any(feature = "internal-mocks", debug_assertions))]
670    {
671        if std::env::var("AGENTD_TEST_KILL_AT").as_deref() == Ok(name) {
672            #[cfg(unix)]
673            unsafe {
674                libc::raise(libc::SIGKILL);
675            }
676            std::process::abort();
677        }
678    }
679    #[cfg(not(any(feature = "internal-mocks", debug_assertions)))]
680    {
681        let _ = name;
682    }
683}
684
685#[cfg(test)]
686mod tests {
687    use super::*;
688    use crate::store::Store;
689    use crate::store::memory::MemoryStore;
690    use std::sync::Arc;
691
692    fn durable(store: Arc<MemoryStore>) -> Durable {
693        Durable::new(
694            store,
695            "agentd",
696            "inst",
697            Policy {
698                debounce: Duration::from_millis(0),
699                ..Policy::default()
700            },
701            None,
702        )
703    }
704
705    #[test]
706    fn put_allocates_seqs_indexes_and_flushes_manifest() {
707        let mem = Arc::new(MemoryStore::new());
708        let d = durable(mem.clone());
709        assert!(d.restore().unwrap().manifest.is_none(), "fresh");
710        assert_eq!(
711            d.put(
712                Kind::Run,
713                "r1",
714                json!({"status": "running"}),
715                Some("h".into())
716            )
717            .unwrap(),
718            1
719        );
720        assert_eq!(
721            d.put(Kind::Run, "r1", json!({"status": "done"}), Some("h".into()))
722                .unwrap(),
723            2
724        );
725        assert_eq!(
726            d.put(Kind::Context, "root", json!({"v": 1}), None).unwrap(),
727            1
728        );
729        let env = d.get(Kind::Run, "r1").unwrap().unwrap();
730        assert_eq!(env.seq, 2);
731        assert_eq!(env.state["status"], json!("done"));
732        assert_eq!(env.hash.as_deref(), Some("h"));
733        // Manifest indexes both, flushed on demand.
734        assert!(d.flush(true).unwrap());
735        let m = d.manifest();
736        assert_eq!(m.entities.len(), 2);
737        assert!(
738            m.entities
739                .iter()
740                .any(|e| e.kind == "run" && e.id == "r1" && e.seq == 2)
741        );
742        assert!(!d.flush(true).unwrap(), "clean after a flush");
743        // delete removes + un-indexes.
744        d.delete(Kind::Context, "root").unwrap();
745        assert!(d.get(Kind::Context, "root").unwrap().is_none());
746        assert_eq!(d.manifest().entities.len(), 1);
747    }
748
749    #[test]
750    fn conflicts_are_fatal_on_owned_keys_but_adopted_on_first_touch() {
751        let mem = Arc::new(MemoryStore::new());
752        // A record from a previous life the manifest never indexed.
753        let stale = Envelope::new("run", "old", 5, "inst", None, json!({"x": 1}));
754        mem.put("agentd/inst/run/old", 5, &stale.to_value())
755            .unwrap();
756        let d = durable(mem.clone());
757        // First touch adopts seq 5 → writes 6.
758        assert_eq!(d.put(Kind::Run, "old", json!({"x": 2}), None).unwrap(), 6);
759        // A genuine second writer bumping the key behind our back is fatal.
760        let other = Envelope::new("run", "old", 7, "other", None, json!({"x": 3}));
761        mem.put("agentd/inst/run/old", 7, &other.to_value())
762            .unwrap();
763        assert!(matches!(
764            d.put(Kind::Run, "old", json!({"x": 4}), None),
765            Err(StoreError::Conflict(_))
766        ));
767    }
768
769    #[test]
770    fn inbox_write_ahead_timers_and_restore() {
771        let mem = Arc::new(MemoryStore::new());
772        {
773            let d = durable(mem.clone());
774            d.restore().unwrap();
775            let e1 = InboxEvent::new(
776                "a2a_message",
777                Some("user:andrii".into()),
778                json!({"text": "hi"}),
779            );
780            let e2 = InboxEvent::new("start_fired", None, json!({"workflow": "w"}));
781            d.inbox_put(&e1).unwrap();
782            d.inbox_put(&e2).unwrap();
783            d.inbox_done(&e1.id).unwrap();
784            d.timer_arm(&TimerRecord {
785                id: "t1".into(),
786                deadline_ms: 42,
787                owner: json!({"run": "r"}),
788                payload: Value::Null,
789            })
790            .unwrap();
791            d.put(
792                Kind::Run,
793                "r",
794                json!({"status": "running"}),
795                Some("hash".into()),
796            )
797            .unwrap();
798            d.put(Kind::Task, "task-1", json!({"state": "working"}), None)
799                .unwrap();
800            d.manifest_update(|m| {
801                m.starts.insert("w.s".into(), json!({"last_fired": 1}));
802            });
803            // A lost entity: indexed but gone from the store.
804            d.put(Kind::Subagent, "gone", json!({}), None).unwrap();
805            d.flush(true).unwrap();
806            mem.delete("agentd/inst/subagent/gone").unwrap();
807            // An entity written AFTER the last flush (entity-first order): not
808            // indexed; the restore's `list` reconciliation finds it.
809            d.put(Kind::Run, "r2", json!({"status": "running"}), None)
810                .unwrap();
811        }
812        // "restart": a fresh Durable over the same store.
813        let d2 = durable(mem.clone());
814        let r = d2.restore().unwrap();
815        let m = r.manifest.as_ref().unwrap();
816        assert_eq!(m.generation, 2, "generation bumped");
817        assert_eq!(m.starts["w.s"]["last_fired"], json!(1));
818        let pending = r.inbox_pending();
819        assert_eq!(pending.len(), 1, "the done event does not replay");
820        assert_eq!(pending[0].kind, "start_fired");
821        assert_eq!(r.timers().len(), 1);
822        assert_eq!(r.timers()[0].deadline_ms, 42);
823        assert_eq!(
824            r.of(Kind::Run).len(),
825            2,
826            "indexed + unindexed runs restored"
827        );
828        assert!(r.unindexed.iter().any(|u| u.id == "r2"));
829        assert!(
830            r.lost
831                .iter()
832                .any(|l| l.kind == "subagent" && l.id == "gone")
833        );
834        assert_eq!(r.of(Kind::Task).len(), 1);
835        // The seq map is warm: the next put of `r` continues the sequence.
836        assert_eq!(
837            d2.put(
838                Kind::Run,
839                "r",
840                json!({"status": "done"}),
841                Some("hash".into())
842            )
843            .unwrap(),
844            2
845        );
846        // And the re-indexed manifest no longer lists the lost entity.
847        assert!(!d2.manifest().entities.iter().any(|e| e.id == "gone"));
848    }
849
850    #[test]
851    fn degrade_policy_keeps_going_and_flags_it() {
852        let mem = Arc::new(MemoryStore::new());
853        let d = Durable::new(
854            mem.clone(),
855            "agentd",
856            "inst",
857            Policy {
858                debounce: Duration::from_millis(0),
859                on_error: crate::config::v2::StoreOnError::Degrade,
860                retries: 1,
861            },
862            None,
863        );
864        mem.fail_next(1);
865        assert_eq!(
866            d.put(Kind::Run, "r", json!({}), None).unwrap(),
867            1,
868            "degraded write reports the intended seq"
869        );
870        assert!(d.is_degraded());
871        assert_eq!(
872            d.put(Kind::Run, "r", json!({}), None).unwrap(),
873            2,
874            "seq not reused"
875        );
876        assert!(!d.is_degraded(), "a successful write clears the flag");
877        // Halt policy surfaces the error.
878        let d2 = durable(mem.clone());
879        mem.fail_next(5);
880        assert!(matches!(
881            d2.put(Kind::Run, "x", json!({}), None),
882            Err(StoreError::Io(_))
883        ));
884    }
885}