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