1pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
29pub enum Kind {
30 Manifest,
31 Inbox,
32 Context,
33 Run,
34 Subagent,
35 Task,
36 Memory,
37 Artifact,
38 Timer,
39 Audit,
40 Cred,
44}
45
46impl Kind {
47 pub fn as_str(self) -> &'static str {
48 match self {
49 Kind::Manifest => "manifest",
50 Kind::Inbox => "inbox",
51 Kind::Context => "context",
52 Kind::Run => "run",
53 Kind::Subagent => "subagent",
54 Kind::Task => "task",
55 Kind::Memory => "memory",
56 Kind::Artifact => "artifact",
57 Kind::Timer => "timer",
58 Kind::Audit => "audit",
59 Kind::Cred => "cred",
60 }
61 }
62 pub fn parse(s: &str) -> Option<Kind> {
63 Some(match s {
64 "manifest" => Kind::Manifest,
65 "inbox" => Kind::Inbox,
66 "context" => Kind::Context,
67 "run" => Kind::Run,
68 "subagent" => Kind::Subagent,
69 "task" => Kind::Task,
70 "memory" => Kind::Memory,
71 "artifact" => Kind::Artifact,
72 "timer" => Kind::Timer,
73 "audit" => Kind::Audit,
74 "cred" => Kind::Cred,
75 _ => return None,
76 })
77 }
78 pub fn indexed(self) -> bool {
82 !matches!(
83 self,
84 Kind::Manifest | Kind::Memory | Kind::Audit | Kind::Cred
85 )
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct EntityRef {
92 pub kind: String,
93 pub id: String,
94 pub seq: u64,
95}
96
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
99pub struct Manifest {
100 #[serde(default)]
101 pub generation: u64,
102 #[serde(default)]
103 pub created: u64,
104 #[serde(default)]
105 pub updated: u64,
106 #[serde(default)]
107 pub entities: Vec<EntityRef>,
108 #[serde(default)]
110 pub starts: BTreeMap<String, Value>,
111 #[serde(default)]
113 pub budget: Value,
114 #[serde(default)]
115 pub lifecycle: Value,
116 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
122 pub config_digest: BTreeMap<String, String>,
123 #[serde(default, skip_serializing_if = "Vec::is_empty")]
128 pub retired: Vec<EntityRef>,
129}
130
131impl Manifest {
132 fn upsert(&mut self, kind: &str, id: &str, seq: u64) {
133 match self
134 .entities
135 .iter_mut()
136 .find(|e| e.kind == kind && e.id == id)
137 {
138 Some(e) => e.seq = seq,
139 None => self.entities.push(EntityRef {
140 kind: kind.to_string(),
141 id: id.to_string(),
142 seq,
143 }),
144 }
145 }
146 fn remove(&mut self, kind: &str, id: &str) {
147 self.entities.retain(|e| !(e.kind == kind && e.id == id));
148 }
149}
150
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
153pub struct InboxEvent {
154 pub id: String,
155 pub kind: String,
156 pub ts: u64,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub principal: Option<String>,
159 pub payload: Value,
160 #[serde(default)]
161 pub status: InboxStatus,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
165#[serde(rename_all = "lowercase")]
166pub enum InboxStatus {
167 #[default]
168 Pending,
169 Done,
170}
171
172impl InboxEvent {
173 pub fn new(kind: &str, principal: Option<String>, payload: Value) -> InboxEvent {
174 InboxEvent {
175 id: ulid::new(),
176 kind: kind.to_string(),
177 ts: now_ms(),
178 principal,
179 payload,
180 status: InboxStatus::Pending,
181 }
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
187pub struct TimerRecord {
188 pub id: String,
189 pub deadline_ms: u64,
190 pub owner: Value,
191 #[serde(default)]
192 pub payload: Value,
193}
194
195#[derive(Debug, Clone)]
198pub struct Policy {
199 pub debounce: Duration,
200 pub on_error: crate::config::v2::StoreOnError,
201 pub retries: u32,
202}
203
204impl Default for Policy {
205 fn default() -> Self {
206 Policy {
207 debounce: Duration::from_millis(250),
208 on_error: crate::config::v2::StoreOnError::Halt,
209 retries: 3,
210 }
211 }
212}
213
214impl Policy {
215 pub fn from_settings(s: &crate::config::v2::Store) -> Policy {
216 Policy {
217 debounce: Duration::from_millis(s.checkpoint.debounce_ms.unwrap_or(250)),
218 on_error: s.on_error,
219 retries: 3,
220 }
221 }
222}
223
224static FRESH: AtomicBool = AtomicBool::new(false);
239static CONFIG_DIGEST: OnceLock<BTreeMap<String, String>> = OnceLock::new();
240
241pub fn request_fresh() {
244 FRESH.store(true, Ordering::Relaxed);
245}
246
247pub fn fresh_requested() -> bool {
249 FRESH.load(Ordering::Relaxed)
250}
251
252pub fn record_config_digest(settings: &crate::config::v2::Settings) {
256 let _ = CONFIG_DIGEST.set(config_digest(settings));
257}
258
259fn recorded_config_digest() -> BTreeMap<String, String> {
260 CONFIG_DIGEST.get().cloned().unwrap_or_default()
261}
262
263pub fn config_digest(settings: &crate::config::v2::Settings) -> BTreeMap<String, String> {
279 let mut out = BTreeMap::new();
280 let digest = |v: &Value| crate::sha::sha256_hex(v.to_string().as_bytes());
283 out.insert(
284 "workflows".to_string(),
285 digest(&Value::Array(settings.workflows.clone())),
286 );
287 out.insert("store".to_string(), digest(&store_shape(&settings.store)));
288 out.insert(
289 "limits".to_string(),
290 digest(&limits_shape(&settings.limits)),
291 );
292 out
293}
294
295fn store_shape(s: &crate::config::v2::Store) -> Value {
300 json!({
301 "kind": format!("{:?}", s.kind),
302 "prefix": s.prefix(),
303 "on_error": format!("{:?}", s.on_error),
304 "audit": s.audit,
305 "checkpoint_debounce_ms": s.checkpoint.debounce_ms,
306 "durability": format!("{:?}", s.durability),
307 "timeout_ms": s.timeout.map(|d| d.0.as_millis() as u64),
308 "mcp_server": s.mcp.as_ref().map(|m| m.server.clone()),
312 "http": s.http.is_some(),
313 })
314}
315
316fn limits_shape(s: &crate::config::v2::Limits) -> Value {
320 json!({
321 "max_runs": s.max_runs,
322 "run_steps": s.run.steps(),
323 "run_tokens": s.run.tokens(),
324 "run_deadline_ms": s.run.deadline().as_millis() as u64,
325 "subagents": format!("{:?}", s.subagents),
326 "inline_max_bytes": s.inline_max_bytes,
327 "step_timeout_ms": s.step_timeout.map(|d| d.0.as_millis() as u64),
328 })
329}
330
331fn changed_sections(
338 recorded: &BTreeMap<String, String>,
339 current: &BTreeMap<String, String>,
340) -> Vec<String> {
341 if recorded.is_empty() || current.is_empty() {
342 return Vec::new();
343 }
344 let mut out: Vec<String> = current
345 .iter()
346 .filter(|(k, v)| recorded.get(*k) != Some(*v))
347 .map(|(k, _)| k.clone())
348 .collect();
349 out.extend(
350 recorded
351 .keys()
352 .filter(|k| !current.contains_key(*k))
353 .cloned(),
354 );
355 out.sort();
356 out.dedup();
357 out
358}
359
360const RECONCILED: [Kind; 7] = [
363 Kind::Inbox,
364 Kind::Context,
365 Kind::Run,
366 Kind::Subagent,
367 Kind::Task,
368 Kind::Timer,
369 Kind::Artifact,
370];
371
372#[derive(Debug, Default)]
374pub struct Restored {
375 pub manifest: Option<Manifest>,
377 pub entities: BTreeMap<String, Vec<Envelope>>,
379 pub lost: Vec<EntityRef>,
381 pub unindexed: Vec<EntityRef>,
384}
385
386impl Restored {
387 pub fn inbox_pending(&self) -> Vec<InboxEvent> {
388 let mut out: Vec<InboxEvent> = self
389 .entities
390 .get("inbox")
391 .map(|v| {
392 v.iter()
393 .filter_map(|e| serde_json::from_value::<InboxEvent>(e.state.clone()).ok())
394 .filter(|e| e.status == InboxStatus::Pending)
395 .collect()
396 })
397 .unwrap_or_default();
398 out.sort_by(|a, b| a.ts.cmp(&b.ts).then(a.id.cmp(&b.id)));
399 out
400 }
401 pub fn timers(&self) -> Vec<TimerRecord> {
402 self.entities
403 .get("timer")
404 .map(|v| {
405 v.iter()
406 .filter_map(|e| serde_json::from_value(e.state.clone()).ok())
407 .collect()
408 })
409 .unwrap_or_default()
410 }
411 pub fn of(&self, kind: Kind) -> &[Envelope] {
412 self.entities
413 .get(kind.as_str())
414 .map(Vec::as_slice)
415 .unwrap_or(&[])
416 }
417 pub fn count(&self) -> usize {
418 self.entities.values().map(Vec::len).sum()
419 }
420}
421
422pub struct Durable {
424 store: SharedStore,
425 prefix: String,
426 instance: String,
427 policy: Policy,
428 seqs: Mutex<HashMap<String, u64>>,
431 manifest: Mutex<Manifest>,
432 manifest_dirty: AtomicBool,
433 last_flush: Mutex<Instant>,
434 degraded: AtomicBool,
435 log: Option<Logger>,
436 fresh: bool,
438 config_digest: BTreeMap<String, String>,
441}
442
443impl Durable {
444 pub fn new(
445 store: SharedStore,
446 prefix: &str,
447 instance: &str,
448 policy: Policy,
449 log: Option<Logger>,
450 ) -> Durable {
451 Durable {
452 store,
453 prefix: prefix.to_string(),
454 instance: instance.to_string(),
455 policy,
456 seqs: Mutex::new(HashMap::new()),
457 manifest: Mutex::new(Manifest::default()),
458 manifest_dirty: AtomicBool::new(false),
459 last_flush: Mutex::new(Instant::now()),
460 degraded: AtomicBool::new(false),
461 log,
462 fresh: fresh_requested(),
463 config_digest: recorded_config_digest(),
464 }
465 }
466
467 pub fn with_fresh(mut self, fresh: bool) -> Durable {
471 self.fresh = fresh;
472 self
473 }
474
475 pub fn with_config_digest(mut self, digest: BTreeMap<String, String>) -> Durable {
477 self.config_digest = digest;
478 self
479 }
480
481 pub fn store_kind(&self) -> &'static str {
482 self.store.kind()
483 }
484 pub fn instance(&self) -> &str {
485 &self.instance
486 }
487 pub fn prefix(&self) -> &str {
488 &self.prefix
489 }
490 pub fn key(&self, kind: Kind, id: &str) -> String {
491 crate::store::key(&self.prefix, &self.instance, kind.as_str(), id)
492 }
493 pub fn is_degraded(&self) -> bool {
495 self.degraded.load(Ordering::Relaxed)
496 }
497 pub fn policy(&self) -> &Policy {
498 &self.policy
499 }
500
501 pub fn put(
508 &self,
509 kind: Kind,
510 id: &str,
511 state: Value,
512 hash: Option<String>,
513 ) -> Result<u64, StoreError> {
514 let key = self.key(kind, id);
515 let mut adopted = false;
516 let started = std::time::Instant::now();
517 loop {
518 let (seq, warmed) = {
519 let seqs = self.seqs.lock().unwrap_or_else(|e| e.into_inner());
520 match seqs.get(&key).copied() {
521 Some(s) => (s + 1, true),
522 None => (1, false),
523 }
524 };
525 let env = Envelope::new(
526 kind.as_str(),
527 id,
528 seq,
529 &self.instance,
530 hash.clone(),
531 state.clone(),
532 );
533 kill_point("state.before_put");
534 let outcome = crate::store::with_retry(
535 || self.store.put(&key, seq, &env.to_value()),
536 self.policy.retries,
537 );
538 match outcome {
539 Ok(PutOutcome::Ok) => {
540 self.seqs
541 .lock()
542 .unwrap_or_else(|e| e.into_inner())
543 .insert(key.clone(), seq);
544 if kind.indexed() {
545 let mut m = self.manifest.lock().unwrap_or_else(|e| e.into_inner());
546 m.upsert(kind.as_str(), id, seq);
547 m.updated = now_ms();
548 self.manifest_dirty.store(true, Ordering::Relaxed);
549 }
550 self.degraded.store(false, Ordering::Relaxed);
551 kill_point("state.after_put");
552 crate::obs::metrics::record_store_op(
553 "ok",
554 started.elapsed().as_millis() as u64,
555 );
556 return Ok(seq);
557 }
558 Ok(PutOutcome::Conflict { latest_seq }) => {
559 if !warmed && !adopted {
560 if let Some(l) = latest_seq {
564 self.seqs
565 .lock()
566 .unwrap_or_else(|e| e.into_inner())
567 .insert(key.clone(), l);
568 adopted = true;
569 self.log_event("store.seq_adopted", json!({"key": key, "latest": l}));
570 continue;
571 }
572 }
573 self.log_event(
574 "store.conflict",
575 json!({"key": key, "seq": seq, "latest": latest_seq}),
576 );
577 crate::obs::metrics::record_store_op(
578 "conflict",
579 started.elapsed().as_millis() as u64,
580 );
581 return Err(StoreError::Conflict(format!(
582 "key {key}: another writer owns it (our seq {seq}, latest {latest_seq:?})"
583 )));
584 }
585 Err(e) => {
586 self.log_event("store.put.fail", json!({"key": key, "err": e.to_string()}));
587 crate::obs::metrics::record_store_op(
588 "error",
589 started.elapsed().as_millis() as u64,
590 );
591 if self.policy.on_error == crate::config::v2::StoreOnError::Degrade {
592 self.degraded.store(true, Ordering::Relaxed);
593 self.seqs
596 .lock()
597 .unwrap_or_else(|e| e.into_inner())
598 .insert(key.clone(), seq);
599 return Ok(seq);
600 }
601 return Err(e);
602 }
603 }
604 }
605 }
606
607 pub fn get(&self, kind: Kind, id: &str) -> Result<Option<Envelope>, StoreError> {
609 let key = self.key(kind, id);
610 let v = crate::store::with_retry(|| self.store.get(&key, None), self.policy.retries)?;
611 match v {
612 None => Ok(None),
613 Some(v) => {
614 let env = Envelope::from_value(v)?;
615 self.seqs
616 .lock()
617 .unwrap_or_else(|e| e.into_inner())
618 .insert(key, env.seq);
619 Ok(if env.is_tombstone() { None } else { Some(env) })
620 }
621 }
622 }
623
624 pub fn delete(&self, kind: Kind, id: &str) -> Result<(), StoreError> {
627 let key = self.key(kind, id);
628 match crate::store::with_retry(|| self.store.delete(&key), self.policy.retries) {
629 Ok(()) => {
630 self.seqs
631 .lock()
632 .unwrap_or_else(|e| e.into_inner())
633 .remove(&key);
634 }
635 Err(StoreError::Unsupported(_)) => {
636 self.put(kind, id, Value::Null, None)?;
637 }
638 Err(e) => return Err(e),
639 }
640 if kind.indexed() {
641 let mut m = self.manifest.lock().unwrap_or_else(|e| e.into_inner());
642 m.remove(kind.as_str(), id);
643 m.updated = now_ms();
644 self.manifest_dirty.store(true, Ordering::Relaxed);
645 }
646 Ok(())
647 }
648
649 pub fn list(&self, kind: Kind) -> Result<Vec<KeySeq>, StoreError> {
651 let prefix = format!("{}/{}/{}/", self.prefix, self.instance, kind.as_str());
652 self.store.list(&prefix)
653 }
654
655 pub fn inbox_put(&self, ev: &InboxEvent) -> Result<u64, StoreError> {
659 let seq = self.put(
660 Kind::Inbox,
661 &ev.id,
662 serde_json::to_value(ev).unwrap_or(Value::Null),
663 None,
664 )?;
665 kill_point("inbox.after_put");
666 Ok(seq)
667 }
668
669 pub fn inbox_done(&self, id: &str) -> Result<(), StoreError> {
671 self.delete(Kind::Inbox, id)
672 }
673
674 pub fn timer_arm(&self, t: &TimerRecord) -> Result<u64, StoreError> {
675 self.put(
676 Kind::Timer,
677 &t.id,
678 serde_json::to_value(t).unwrap_or(Value::Null),
679 None,
680 )
681 }
682
683 pub fn timer_disarm(&self, id: &str) -> Result<(), StoreError> {
684 self.delete(Kind::Timer, id)
685 }
686
687 pub fn manifest(&self) -> Manifest {
690 self.manifest
691 .lock()
692 .unwrap_or_else(|e| e.into_inner())
693 .clone()
694 }
695
696 pub fn manifest_update(&self, f: impl FnOnce(&mut Manifest)) {
699 let mut m = self.manifest.lock().unwrap_or_else(|e| e.into_inner());
700 f(&mut m);
701 m.updated = now_ms();
702 self.manifest_dirty.store(true, Ordering::Relaxed);
703 }
704
705 pub fn flush(&self, force: bool) -> Result<bool, StoreError> {
707 if !self.manifest_dirty.load(Ordering::Relaxed) {
708 return Ok(false);
709 }
710 {
711 let last = self.last_flush.lock().unwrap_or_else(|e| e.into_inner());
712 if !force && last.elapsed() < self.policy.debounce {
713 return Ok(false);
714 }
715 }
716 let snapshot = self.manifest();
717 self.put(
718 Kind::Manifest,
719 "agent",
720 serde_json::to_value(&snapshot).unwrap_or(Value::Null),
721 None,
722 )?;
723 self.manifest_dirty.store(false, Ordering::Relaxed);
724 *self.last_flush.lock().unwrap_or_else(|e| e.into_inner()) = Instant::now();
725 Ok(true)
726 }
727
728 pub fn restore(&self) -> Result<Restored, StoreError> {
738 let mut out = Restored::default();
739 let (manifest, fresh) = match self.get(Kind::Manifest, "agent")? {
740 None => (
741 Manifest {
742 generation: 0,
743 created: now_ms(),
744 updated: now_ms(),
745 ..Manifest::default()
746 },
747 true,
748 ),
749 Some(env) => (
750 serde_json::from_value::<Manifest>(env.state.clone())
751 .map_err(|e| StoreError::Corrupt(format!("manifest does not parse: {e}")))?,
752 false,
753 ),
754 };
755 if self.fresh {
760 return self.restore_fresh(manifest, fresh);
761 }
762 let moved = changed_sections(&manifest.config_digest, &self.config_digest);
769 if !moved.is_empty() {
770 self.log_event(
771 "store.config_changed",
772 json!({
773 "sections": moved,
774 "msg": "state was written under a different configuration — resuming anyway; --fresh to start a new generation",
775 }),
776 );
777 }
778 for r in &manifest.entities {
780 let Some(kind) = Kind::parse(&r.kind) else {
781 out.lost.push(r.clone());
782 continue;
783 };
784 match self.get(kind, &r.id)? {
785 Some(env) => out.entities.entry(r.kind.clone()).or_default().push(env),
786 None => out.lost.push(r.clone()),
787 }
788 }
789 let mut seen: Option<BTreeSet<(String, String)>> = None;
794 for kind in RECONCILED {
795 match self.list(kind) {
796 Ok(keys) => {
797 let seen = seen.get_or_insert_with(BTreeSet::new);
798 for ks in keys {
799 let Some((_, id)) =
800 crate::store::parse_key(&self.prefix, &self.instance, &ks.key)
801 else {
802 continue;
803 };
804 seen.insert((kind.as_str().to_string(), id.to_string()));
805 let indexed = manifest
806 .entities
807 .iter()
808 .any(|e| e.kind == kind.as_str() && e.id == id);
809 if indexed {
810 continue;
811 }
812 if manifest
817 .retired
818 .iter()
819 .any(|r| r.kind == kind.as_str() && r.id == id)
820 {
821 continue;
822 }
823 if let Some(env) = self.get(kind, id)? {
824 out.unindexed.push(EntityRef {
825 kind: kind.as_str().to_string(),
826 id: id.to_string(),
827 seq: env.seq,
828 });
829 out.entities
830 .entry(kind.as_str().to_string())
831 .or_default()
832 .push(env);
833 }
834 }
835 }
836 Err(StoreError::Unsupported(_)) => {}
837 Err(e) => return Err(e),
838 }
839 }
840 let mut m = manifest.clone();
844 m.entities.retain(|e| !out.lost.iter().any(|l| l == e));
845 for u in &out.unindexed {
846 m.upsert(&u.kind, &u.id, u.seq);
847 }
848 m.generation += 1;
849 m.updated = now_ms();
850 if !self.config_digest.is_empty() {
855 m.config_digest = self.config_digest.clone();
856 }
857 if let Some(seen) = &seen {
861 m.retired
862 .retain(|r| seen.contains(&(r.kind.clone(), r.id.clone())));
863 }
864 *self.manifest.lock().unwrap_or_else(|e| e.into_inner()) = m.clone();
865 self.manifest_dirty.store(true, Ordering::Relaxed);
866 self.flush(true)?;
867 if fresh && out.count() == 0 {
868 self.log_event("restore.fresh", json!({"generation": m.generation}));
869 return Ok(out);
870 }
871 self.log_event(
872 "restore.done",
873 json!({
874 "generation": m.generation,
875 "fresh_manifest": fresh,
876 "entities": out.count(),
877 "lost": out.lost.len(),
878 "unindexed": out.unindexed.len(),
879 "inbox_pending": out.inbox_pending().len(),
880 }),
881 );
882 out.manifest = Some(m);
883 Ok(out)
884 }
885
886 fn restore_fresh(
902 &self,
903 prior: Manifest,
904 no_prior_manifest: bool,
905 ) -> Result<Restored, StoreError> {
906 let mut retired: Vec<EntityRef> = Vec::new();
909 let mut push = |kind: &str, id: &str, seq: u64| {
910 if !retired.iter().any(|r| r.kind == kind && r.id == id) {
911 retired.push(EntityRef {
912 kind: kind.to_string(),
913 id: id.to_string(),
914 seq,
915 });
916 }
917 };
918 for kind in RECONCILED {
919 match self.list(kind) {
920 Ok(keys) => {
921 for ks in keys {
922 if let Some((_, id)) =
923 crate::store::parse_key(&self.prefix, &self.instance, &ks.key)
924 {
925 push(kind.as_str(), id, ks.seq.unwrap_or(0));
926 }
927 }
928 }
929 Err(StoreError::Unsupported(_)) => {}
930 Err(e) => return Err(e),
931 }
932 }
933 for e in &prior.entities {
934 push(&e.kind, &e.id, e.seq);
935 }
936 for e in &prior.retired {
937 push(&e.kind, &e.id, e.seq);
938 }
939 if !no_prior_manifest {
942 self.put(
943 Kind::Manifest,
944 &format!("agent.gen{}", prior.generation),
945 serde_json::to_value(&prior).unwrap_or(Value::Null),
946 None,
947 )?;
948 }
949 let m = Manifest {
952 generation: prior.generation + 1,
953 created: if prior.created == 0 {
954 now_ms()
955 } else {
956 prior.created
957 },
958 updated: now_ms(),
959 entities: Vec::new(),
960 starts: BTreeMap::new(),
961 budget: Value::Null,
962 lifecycle: Value::Null,
963 config_digest: self.config_digest.clone(),
964 retired,
965 };
966 *self.manifest.lock().unwrap_or_else(|e| e.into_inner()) = m.clone();
967 self.manifest_dirty.store(true, Ordering::Relaxed);
968 self.flush(true)?;
969 self.log_event(
970 "restore.fresh",
971 json!({
972 "generation": m.generation,
973 "superseded": prior.generation,
974 "retired": m.retired.len(),
975 "msg": "--fresh: this generation starts empty; the previous one's records were kept, not deleted",
976 }),
977 );
978 Ok(Restored::default())
979 }
980
981 fn log_event(&self, event: &str, fields: Value) {
982 if let Some(l) = &self.log {
983 match event {
984 e if e.ends_with(".fail")
985 || e == "store.conflict"
986 || e == "store.config_changed" =>
989 {
990 l.warn(event, fields)
991 }
992 _ => l.info(event, fields),
993 }
994 }
995 }
996}
997
998pub fn kill_point(name: &str) {
1002 #[cfg(any(feature = "internal-mocks", debug_assertions))]
1003 {
1004 if std::env::var("AGENTD_TEST_KILL_AT").as_deref() == Ok(name) {
1005 #[cfg(unix)]
1006 unsafe {
1007 libc::raise(libc::SIGKILL);
1008 }
1009 std::process::abort();
1010 }
1011 }
1012 #[cfg(not(any(feature = "internal-mocks", debug_assertions)))]
1013 {
1014 let _ = name;
1015 }
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020 use super::*;
1021 use crate::store::Store;
1022 use crate::store::memory::MemoryStore;
1023 use std::sync::Arc;
1024
1025 fn durable(store: Arc<MemoryStore>) -> Durable {
1026 Durable::new(
1027 store,
1028 "agentd",
1029 "inst",
1030 Policy {
1031 debounce: Duration::from_millis(0),
1032 ..Policy::default()
1033 },
1034 None,
1035 )
1036 }
1037
1038 #[test]
1039 fn put_allocates_seqs_indexes_and_flushes_manifest() {
1040 let mem = Arc::new(MemoryStore::new());
1041 let d = durable(mem.clone());
1042 assert!(d.restore().unwrap().manifest.is_none(), "fresh");
1043 assert_eq!(
1044 d.put(
1045 Kind::Run,
1046 "r1",
1047 json!({"status": "running"}),
1048 Some("h".into())
1049 )
1050 .unwrap(),
1051 1
1052 );
1053 assert_eq!(
1054 d.put(Kind::Run, "r1", json!({"status": "done"}), Some("h".into()))
1055 .unwrap(),
1056 2
1057 );
1058 assert_eq!(
1059 d.put(Kind::Context, "root", json!({"v": 1}), None).unwrap(),
1060 1
1061 );
1062 let env = d.get(Kind::Run, "r1").unwrap().unwrap();
1063 assert_eq!(env.seq, 2);
1064 assert_eq!(env.state["status"], json!("done"));
1065 assert_eq!(env.hash.as_deref(), Some("h"));
1066 assert!(d.flush(true).unwrap());
1068 let m = d.manifest();
1069 assert_eq!(m.entities.len(), 2);
1070 assert!(
1071 m.entities
1072 .iter()
1073 .any(|e| e.kind == "run" && e.id == "r1" && e.seq == 2)
1074 );
1075 assert!(!d.flush(true).unwrap(), "clean after a flush");
1076 d.delete(Kind::Context, "root").unwrap();
1078 assert!(d.get(Kind::Context, "root").unwrap().is_none());
1079 assert_eq!(d.manifest().entities.len(), 1);
1080 }
1081
1082 #[test]
1083 fn conflicts_are_fatal_on_owned_keys_but_adopted_on_first_touch() {
1084 let mem = Arc::new(MemoryStore::new());
1085 let stale = Envelope::new("run", "old", 5, "inst", None, json!({"x": 1}));
1087 mem.put("agentd/inst/run/old", 5, &stale.to_value())
1088 .unwrap();
1089 let d = durable(mem.clone());
1090 assert_eq!(d.put(Kind::Run, "old", json!({"x": 2}), None).unwrap(), 6);
1092 let other = Envelope::new("run", "old", 7, "other", None, json!({"x": 3}));
1094 mem.put("agentd/inst/run/old", 7, &other.to_value())
1095 .unwrap();
1096 assert!(matches!(
1097 d.put(Kind::Run, "old", json!({"x": 4}), None),
1098 Err(StoreError::Conflict(_))
1099 ));
1100 }
1101
1102 #[test]
1103 fn inbox_write_ahead_timers_and_restore() {
1104 let mem = Arc::new(MemoryStore::new());
1105 {
1106 let d = durable(mem.clone());
1107 d.restore().unwrap();
1108 let e1 = InboxEvent::new(
1109 "a2a_message",
1110 Some("user:andrii".into()),
1111 json!({"text": "hi"}),
1112 );
1113 let e2 = InboxEvent::new("start_fired", None, json!({"workflow": "w"}));
1114 d.inbox_put(&e1).unwrap();
1115 d.inbox_put(&e2).unwrap();
1116 d.inbox_done(&e1.id).unwrap();
1117 d.timer_arm(&TimerRecord {
1118 id: "t1".into(),
1119 deadline_ms: 42,
1120 owner: json!({"run": "r"}),
1121 payload: Value::Null,
1122 })
1123 .unwrap();
1124 d.put(
1125 Kind::Run,
1126 "r",
1127 json!({"status": "running"}),
1128 Some("hash".into()),
1129 )
1130 .unwrap();
1131 d.put(Kind::Task, "task-1", json!({"state": "working"}), None)
1132 .unwrap();
1133 d.manifest_update(|m| {
1134 m.starts.insert("w.s".into(), json!({"last_fired": 1}));
1135 });
1136 d.put(Kind::Subagent, "gone", json!({}), None).unwrap();
1138 d.flush(true).unwrap();
1139 mem.delete("agentd/inst/subagent/gone").unwrap();
1140 d.put(Kind::Run, "r2", json!({"status": "running"}), None)
1143 .unwrap();
1144 }
1145 let d2 = durable(mem.clone());
1147 let r = d2.restore().unwrap();
1148 let m = r.manifest.as_ref().unwrap();
1149 assert_eq!(m.generation, 2, "generation bumped");
1150 assert_eq!(m.starts["w.s"]["last_fired"], json!(1));
1151 let pending = r.inbox_pending();
1152 assert_eq!(pending.len(), 1, "the done event does not replay");
1153 assert_eq!(pending[0].kind, "start_fired");
1154 assert_eq!(r.timers().len(), 1);
1155 assert_eq!(r.timers()[0].deadline_ms, 42);
1156 assert_eq!(
1157 r.of(Kind::Run).len(),
1158 2,
1159 "indexed + unindexed runs restored"
1160 );
1161 assert!(r.unindexed.iter().any(|u| u.id == "r2"));
1162 assert!(
1163 r.lost
1164 .iter()
1165 .any(|l| l.kind == "subagent" && l.id == "gone")
1166 );
1167 assert_eq!(r.of(Kind::Task).len(), 1);
1168 assert_eq!(
1170 d2.put(
1171 Kind::Run,
1172 "r",
1173 json!({"status": "done"}),
1174 Some("hash".into())
1175 )
1176 .unwrap(),
1177 2
1178 );
1179 assert!(!d2.manifest().entities.iter().any(|e| e.id == "gone"));
1181 }
1182
1183 #[test]
1184 fn degrade_policy_keeps_going_and_flags_it() {
1185 let mem = Arc::new(MemoryStore::new());
1186 let d = Durable::new(
1187 mem.clone(),
1188 "agentd",
1189 "inst",
1190 Policy {
1191 debounce: Duration::from_millis(0),
1192 on_error: crate::config::v2::StoreOnError::Degrade,
1193 retries: 1,
1194 },
1195 None,
1196 );
1197 mem.fail_next(1);
1198 assert_eq!(
1199 d.put(Kind::Run, "r", json!({}), None).unwrap(),
1200 1,
1201 "degraded write reports the intended seq"
1202 );
1203 assert!(d.is_degraded());
1204 assert_eq!(
1205 d.put(Kind::Run, "r", json!({}), None).unwrap(),
1206 2,
1207 "seq not reused"
1208 );
1209 assert!(!d.is_degraded(), "a successful write clears the flag");
1210 let d2 = durable(mem.clone());
1212 mem.fail_next(5);
1213 assert!(matches!(
1214 d2.put(Kind::Run, "x", json!({}), None),
1215 Err(StoreError::Io(_))
1216 ));
1217 }
1218
1219 #[test]
1224 fn fresh_opens_a_new_generation_without_resuming_and_deletes_nothing() {
1225 let mem = Arc::new(MemoryStore::new());
1226
1227 let d = durable(mem.clone());
1229 assert!(d.restore().unwrap().manifest.is_none());
1230 d.put(Kind::Run, "r1", json!({"status": "running"}), None)
1231 .unwrap();
1232 let ev = InboxEvent::new("a2a.message", None, json!({"n": 1}));
1233 d.inbox_put(&ev).unwrap();
1234 assert_eq!(d.manifest().generation, 1);
1235
1236 let f = durable(mem.clone()).with_fresh(true);
1238 let r = f.restore().unwrap();
1239 assert!(
1240 r.manifest.is_none(),
1241 "a new generation reports no prior life"
1242 );
1243 assert_eq!(r.count(), 0);
1244 assert!(r.inbox_pending().is_empty(), "the inbox does not replay");
1245 let m = f.manifest();
1246 assert_eq!(m.generation, 2, "the counter is inherited, not reset");
1247 assert!(m.entities.is_empty());
1248
1249 assert!(
1251 f.get(Kind::Run, "r1").unwrap().is_some(),
1252 "--fresh keeps the abandoned records"
1253 );
1254 let kept = f
1255 .get(Kind::Manifest, "agent.gen1")
1256 .unwrap()
1257 .expect("the outgoing manifest is preserved");
1258 let kept: Manifest = serde_json::from_value(kept.state).unwrap();
1259 assert_eq!(kept.generation, 1);
1260 assert!(m.retired.iter().any(|e| e.kind == "run" && e.id == "r1"));
1261 assert!(m.retired.iter().any(|e| e.kind == "inbox" && e.id == ev.id));
1262
1263 let d3 = durable(mem.clone());
1266 let r3 = d3.restore().unwrap();
1267 assert_eq!(r3.count(), 0, "retired records stay retired");
1268 assert!(r3.unindexed.is_empty());
1269 assert_eq!(d3.manifest().generation, 3);
1270 }
1271
1272 #[test]
1276 fn a_moved_config_digest_reports_but_never_gates_the_resume() {
1277 let before: BTreeMap<String, String> = [
1278 ("workflows".to_string(), "aaa".to_string()),
1279 ("store".to_string(), "sss".to_string()),
1280 ]
1281 .into_iter()
1282 .collect();
1283 let mut after = before.clone();
1284 after.insert("workflows".to_string(), "bbb".to_string());
1285 assert_eq!(changed_sections(&before, &after), vec!["workflows"]);
1286 assert!(changed_sections(&BTreeMap::new(), &after).is_empty());
1289 assert!(changed_sections(&before, &BTreeMap::new()).is_empty());
1290
1291 let mem = Arc::new(MemoryStore::new());
1292 let d = durable(mem.clone()).with_config_digest(before.clone());
1293 d.restore().unwrap();
1294 d.put(Kind::Run, "r1", json!({"status": "running"}), None)
1295 .unwrap();
1296 assert_eq!(d.manifest().config_digest, before);
1297
1298 let d2 = durable(mem.clone()).with_config_digest(after.clone());
1299 let r = d2.restore().unwrap();
1300 assert_eq!(r.of(Kind::Run).len(), 1, "state is still resumed");
1301 assert_eq!(d2.manifest().generation, 2);
1302 assert_eq!(
1303 d2.manifest().config_digest,
1304 after,
1305 "the next life compares against what this one ran under"
1306 );
1307 }
1308
1309 #[test]
1312 fn the_digest_covers_workflows_store_and_limits_only() {
1313 let doc = json!({
1314 "config_version": "2",
1315 "agent": {"name": "a", "instruction": "one"},
1316 "workflows": [{"name": "w", "version": 3, "steps": {"s": {"kind": "once"}}}],
1317 "limits": {"run": {"steps": 10}},
1318 });
1319 let settings = |patch: &dyn Fn(&mut Value)| {
1320 let mut d = doc.clone();
1321 patch(&mut d);
1322 serde_json::from_value::<crate::config::v2::Settings>(d).expect("settings")
1323 };
1324 let base = config_digest(&settings(&|_| {}));
1325 assert_eq!(
1326 base.keys().collect::<Vec<_>>(),
1327 ["limits", "store", "workflows"]
1328 );
1329
1330 let elsewhere = config_digest(&settings(&|d| d["agent"]["instruction"] = json!("two")));
1331 assert_eq!(base, elsewhere, "an instruction edit is not a state change");
1332
1333 let wf = config_digest(&settings(&|d| {
1334 d["workflows"][0]["steps"]["t"] = json!({"kind": "noop"})
1335 }));
1336 assert_eq!(changed_sections(&base, &wf), vec!["workflows"]);
1337
1338 let lim = config_digest(&settings(&|d| d["limits"]["run"]["steps"] = json!(11)));
1339 assert_eq!(changed_sections(&base, &lim), vec!["limits"]);
1340 }
1341}