use std::collections::HashSet;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::{Arc, RwLock as StdRwLock};
use std::time::Duration;
use zeph_common::anchor::{Anchor, AnchorError, AnchorStore, AnchorSubsystem, parse_anchor_key};
use zeph_common::task_supervisor::{RestartPolicy, TaskDescriptor, TaskSupervisor};
use crate::vault::AgeVaultProvider;
pub struct AgeVaultAnchorStore {
vault: Arc<StdRwLock<AgeVaultProvider>>,
supervisor: TaskSupervisor,
}
const ANCHOR_GET_SYNC_TIMEOUT: Duration = Duration::from_secs(5);
const ANCHOR_GET_SYNC_POLL_INTERVAL: Duration = Duration::from_millis(10);
fn decode_anchor(value: Option<&str>) -> Result<Option<Anchor>, AnchorError> {
match value {
Some(json) => serde_json::from_str(json)
.map(Some)
.map_err(|e| AnchorError::Store(format!("anchor JSON decode failed: {e}"))),
None => Ok(None),
}
}
impl AgeVaultAnchorStore {
#[must_use]
pub fn new(vault: Arc<StdRwLock<AgeVaultProvider>>, supervisor: TaskSupervisor) -> Self {
Self { vault, supervisor }
}
fn get_sync_bounded(
&self,
subsystem: AnchorSubsystem,
file_id: &[u8],
timeout: Duration,
) -> Result<Option<Anchor>, AnchorError> {
let key = zeph_common::anchor::anchor_key(subsystem, file_id);
let deadline = std::time::Instant::now() + timeout;
loop {
match self.vault.try_read() {
Ok(guard) => return decode_anchor(guard.get(&key)),
Err(std::sync::TryLockError::Poisoned(poisoned)) => {
return decode_anchor(poisoned.into_inner().get(&key));
}
Err(std::sync::TryLockError::WouldBlock) => {
if std::time::Instant::now() >= deadline {
return Err(AnchorError::Store(format!(
"vault read lock timed out after {timeout:?} — failing closed \
rather than blocking indefinitely"
)));
}
std::thread::sleep(ANCHOR_GET_SYNC_POLL_INTERVAL);
}
}
}
}
}
impl AnchorStore for AgeVaultAnchorStore {
fn get(
&self,
subsystem: AnchorSubsystem,
file_id: &[u8],
) -> Pin<Box<dyn Future<Output = Result<Option<Anchor>, AnchorError>> + Send + '_>> {
let key = zeph_common::anchor::anchor_key(subsystem, file_id);
let vault = Arc::clone(&self.vault);
let supervisor = self.supervisor.clone();
Box::pin(async move {
let handle = supervisor.spawn_blocking(Arc::from("anchor-get"), move || {
let guard = vault
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
decode_anchor(guard.get(&key))
});
handle
.join()
.await
.map_err(|e| AnchorError::Store(format!("spawn_blocking: {e}")))?
})
}
fn get_sync(
&self,
subsystem: AnchorSubsystem,
file_id: &[u8],
) -> Result<Option<Anchor>, AnchorError> {
self.get_sync_bounded(subsystem, file_id, ANCHOR_GET_SYNC_TIMEOUT)
}
fn put(
&self,
subsystem: AnchorSubsystem,
file_id: &[u8],
anchor: Anchor,
) -> Pin<Box<dyn Future<Output = Result<(), AnchorError>> + Send + '_>> {
let key = zeph_common::anchor::anchor_key(subsystem, file_id);
let vault = Arc::clone(&self.vault);
let supervisor = self.supervisor.clone();
Box::pin(async move {
let json = serde_json::to_string(&anchor)
.map_err(|e| AnchorError::Store(format!("anchor JSON encode failed: {e}")))?;
let handle = supervisor.spawn_blocking(Arc::from("anchor-put"), move || {
let mut guard = vault
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard
.set_secret_mut(key, json, true)
.map_err(|e| e.to_string())?;
guard.save().map_err(|e| e.to_string())
});
handle
.join()
.await
.map_err(|e| AnchorError::Store(format!("spawn_blocking: {e}")))?
.map_err(AnchorError::Store)
})
}
fn delete(
&self,
subsystem: AnchorSubsystem,
file_id: &[u8],
) -> Pin<Box<dyn Future<Output = Result<(), AnchorError>> + Send + '_>> {
let key = zeph_common::anchor::anchor_key(subsystem, file_id);
let vault = Arc::clone(&self.vault);
let supervisor = self.supervisor.clone();
Box::pin(async move {
let handle = supervisor.spawn_blocking(Arc::from("anchor-delete"), move || {
let mut guard = vault
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !guard.remove_secret_mut(&key) {
return Ok(()); }
guard.save().map_err(|e| e.to_string())
});
handle
.join()
.await
.map_err(|e| AnchorError::Store(format!("spawn_blocking: {e}")))?
.map_err(AnchorError::Store)
})
}
}
pub fn install_anchor_store(store: Option<Arc<dyn AnchorStore>>) {
zeph_subagent::transcript::configure_anchor_store(store.clone());
zeph_session::log::configure_anchor_store(store);
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AnchorSweepReport {
pub orphans_reaped: usize,
pub orphans_stamped: usize,
pub orphans_cleared: usize,
pub evicted_for_cap: usize,
}
const ORPHAN_REAP_GRACE_MS: u64 = 24 * 60 * 60 * 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OrphanAction {
Stamp,
WithinGrace,
Reap,
}
fn plan_orphan_action(orphaned_since: Option<u64>, now: u64) -> OrphanAction {
match orphaned_since {
None => OrphanAction::Stamp,
Some(since) if now.saturating_sub(since) >= ORPHAN_REAP_GRACE_MS => OrphanAction::Reap,
Some(_) => OrphanAction::WithinGrace,
}
}
#[derive(Default)]
struct SweepPlan {
orphans_to_reap: Vec<String>,
orphans_to_stamp: Vec<(String, String)>,
orphans_to_clear: Vec<(String, String)>,
evictions: Vec<String>,
}
fn plan_sweep(
snapshot: &[(String, String)],
transcript_dir: &Path,
sessions_data_dir: &Path,
max_session_anchors: usize,
now: u64,
) -> SweepPlan {
let mut plan = SweepPlan::default();
let mut live_session_anchors: Vec<(String, u64)> = Vec::new();
for (key, json) in snapshot {
let Some((subsystem, file_id)) = parse_anchor_key(key) else {
continue; };
let file_id_str = String::from_utf8_lossy(&file_id).into_owned();
let exists = match subsystem {
AnchorSubsystem::SubagentTranscript => {
transcript_dir.join(format!("{file_id_str}.jsonl")).exists()
}
AnchorSubsystem::SessionLog => {
zeph_session::session_dir(sessions_data_dir, &file_id_str).exists()
}
};
let Some(anchor) = serde_json::from_str::<Anchor>(json).ok() else {
if exists {
if subsystem == AnchorSubsystem::SessionLog {
live_session_anchors.push((key.clone(), 0));
}
} else {
plan.orphans_to_reap.push(key.clone());
}
continue;
};
if exists {
if anchor.orphaned_since.is_some() {
let mut cleared = anchor.clone();
cleared.orphaned_since = None;
if let Ok(new_json) = serde_json::to_string(&cleared) {
plan.orphans_to_clear.push((key.clone(), new_json));
}
}
if subsystem == AnchorSubsystem::SessionLog {
live_session_anchors.push((key.clone(), anchor.written_at));
}
continue;
}
match plan_orphan_action(anchor.orphaned_since, now) {
OrphanAction::Reap => plan.orphans_to_reap.push(key.clone()),
OrphanAction::WithinGrace => {}
OrphanAction::Stamp => {
let mut stamped = anchor;
stamped.orphaned_since = Some(now);
if let Ok(new_json) = serde_json::to_string(&stamped) {
plan.orphans_to_stamp.push((key.clone(), new_json));
}
}
}
}
if live_session_anchors.len() > max_session_anchors {
live_session_anchors.sort_by_key(|(_, written_at)| *written_at);
let to_evict = live_session_anchors.len() - max_session_anchors;
plan.evictions.extend(
live_session_anchors
.into_iter()
.take(to_evict)
.map(|(key, _)| key),
);
}
plan
}
pub fn run_anchor_sweep(
vault: &Arc<StdRwLock<AgeVaultProvider>>,
transcript_dir: &Path,
sessions_data_dir: &Path,
max_session_anchors: usize,
now: u64,
) -> Result<AnchorSweepReport, String> {
let snapshot: Vec<(String, String)> = {
let guard = vault
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard
.list_keys()
.into_iter()
.filter(|k| k.starts_with(zeph_common::anchor::ANCHOR_KEY_PREFIX))
.filter_map(|k| guard.get(k).map(|v| (k.to_owned(), v.to_owned())))
.collect()
};
let plan = plan_sweep(
&snapshot,
transcript_dir,
sessions_data_dir,
max_session_anchors,
now,
);
let mut report = AnchorSweepReport::default();
let has_writes = !plan.orphans_to_reap.is_empty()
|| !plan.orphans_to_stamp.is_empty()
|| !plan.orphans_to_clear.is_empty()
|| !plan.evictions.is_empty();
if has_writes {
let mut guard = vault
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for (key, json) in plan.orphans_to_stamp {
if guard.set_secret_mut(key, json, true).is_ok() {
report.orphans_stamped += 1;
}
}
for (key, json) in plan.orphans_to_clear {
if guard.set_secret_mut(key, json, true).is_ok() {
report.orphans_cleared += 1;
}
}
for key in &plan.orphans_to_reap {
if guard.remove_secret_mut(key) {
report.orphans_reaped += 1;
}
}
for key in &plan.evictions {
if guard.remove_secret_mut(key) {
report.evicted_for_cap += 1;
}
}
guard.save().map_err(|e| e.to_string())?;
}
Ok(report)
}
const SWEEP_INTERVAL: Duration = Duration::from_hours(1);
pub fn spawn_anchor_sweep(
supervisor: &TaskSupervisor,
vault: Arc<StdRwLock<AgeVaultProvider>>,
transcript_dir: PathBuf,
sessions_data_dir: PathBuf,
max_session_anchors: usize,
) {
let sweep_supervisor = supervisor.clone();
supervisor.spawn(TaskDescriptor {
name: "anchor-reconcile-sweep",
restart: RestartPolicy::RunOnce,
factory: move || {
let vault = Arc::clone(&vault);
let transcript_dir = transcript_dir.clone();
let sessions_data_dir = sessions_data_dir.clone();
let blocking = sweep_supervisor.clone();
async move {
let tick = move |vault: Arc<StdRwLock<AgeVaultProvider>>,
transcript_dir: PathBuf,
sessions_data_dir: PathBuf,
blocking: TaskSupervisor| async move {
let handle =
blocking.spawn_blocking(Arc::from("anchor-sweep-tick"), move || {
run_anchor_sweep(
&vault,
&transcript_dir,
&sessions_data_dir,
max_session_anchors,
zeph_common::anchor::now_unix_millis(),
)
});
match handle.join().await {
Ok(Ok(report))
if report.orphans_reaped > 0
|| report.orphans_stamped > 0
|| report.orphans_cleared > 0
|| report.evicted_for_cap > 0 =>
{
tracing::info!(
orphans_reaped = report.orphans_reaped,
orphans_stamped = report.orphans_stamped,
orphans_cleared = report.orphans_cleared,
evicted_for_cap = report.evicted_for_cap,
"anchor reconcile-and-cap sweep completed"
);
}
Ok(Ok(_)) => {}
Ok(Err(e)) => tracing::warn!(error = %e, "anchor sweep failed"),
Err(e) => tracing::warn!(error = %e, "anchor sweep task failed"),
}
};
tick(
Arc::clone(&vault),
transcript_dir.clone(),
sessions_data_dir.clone(),
blocking.clone(),
)
.await;
let mut interval = tokio::time::interval(SWEEP_INTERVAL);
interval.tick().await; loop {
interval.tick().await;
tick(
Arc::clone(&vault),
transcript_dir.clone(),
sessions_data_dir.clone(),
blocking.clone(),
)
.await;
}
}
},
});
}
#[must_use]
pub fn load_durable_integrity_seal(
provider: &AgeVaultProvider,
) -> (bool, HashSet<zeph_durable::ExecutionId>) {
let sealed = provider.get(DURABLE_INTEGRITY_SEALED_KEY).is_some();
let grandfather = provider
.get(DURABLE_INTEGRITY_GRANDFATHER_KEY)
.map(parse_grandfather_set)
.unwrap_or_default();
(sealed, grandfather)
}
pub const DURABLE_INTEGRITY_SEALED_KEY: &str = "ZEPH_DURABLE_INTEGRITY_SEALED";
pub const DURABLE_INTEGRITY_GRANDFATHER_KEY: &str = "ZEPH_DURABLE_INTEGRITY_GRANDFATHER";
#[must_use]
pub fn parse_grandfather_set(value: &str) -> HashSet<zeph_durable::ExecutionId> {
value
.split(',')
.filter_map(|s| zeph_durable::ExecutionId::parse_str(s.trim()).ok())
.collect()
}
#[must_use]
#[allow(clippy::implicit_hasher)]
pub fn render_grandfather_set(
existing: &str,
new_ids: &HashSet<zeph_durable::ExecutionId>,
) -> String {
let mut all: HashSet<zeph_durable::ExecutionId> = parse_grandfather_set(existing);
all.extend(new_ids.iter().copied());
let mut ids: Vec<String> = all.iter().map(|id| id.as_uuid().to_string()).collect();
ids.sort_unstable();
ids.join(",")
}
#[cfg(test)]
mod tests {
use super::*;
use tokio_util::sync::CancellationToken;
use zeph_common::anchor::AnchorSubsystem;
fn test_vault(dir: &Path) -> Arc<StdRwLock<AgeVaultProvider>> {
AgeVaultProvider::init_vault(dir).unwrap();
let provider =
AgeVaultProvider::load(&dir.join("vault-key.txt"), &dir.join("secrets.age")).unwrap();
Arc::new(StdRwLock::new(provider))
}
#[tokio::test]
async fn put_get_delete_round_trip() {
let dir = tempfile::tempdir().unwrap();
let vault = test_vault(dir.path());
let supervisor = TaskSupervisor::new(CancellationToken::new());
let store = AgeVaultAnchorStore::new(vault, supervisor);
let head = zeph_common::hash_chain::chain_next(
&zeph_common::hash_chain::ChainKey::new([1u8; 32]),
&zeph_common::hash_chain::genesis(
&zeph_common::hash_chain::ChainKey::new([1u8; 32]),
"d",
b"f",
0,
),
b"content",
);
let anchor = Anchor::new(0, 5, head);
assert!(
store
.get(AnchorSubsystem::SubagentTranscript, b"task-1")
.await
.unwrap()
.is_none()
);
store
.put(
AnchorSubsystem::SubagentTranscript,
b"task-1",
anchor.clone(),
)
.await
.unwrap();
let fetched = store
.get(AnchorSubsystem::SubagentTranscript, b"task-1")
.await
.unwrap()
.unwrap();
assert_eq!(fetched.count, 5);
assert_eq!(fetched.head_hex, anchor.head_hex);
store
.delete(AnchorSubsystem::SubagentTranscript, b"task-1")
.await
.unwrap();
assert!(
store
.get(AnchorSubsystem::SubagentTranscript, b"task-1")
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn get_is_a_real_suspension_point_and_honors_an_external_timeout() {
let outcome = tokio::time::timeout(Duration::from_secs(5), async {
let dir = tempfile::tempdir().unwrap();
let vault = test_vault(dir.path());
let supervisor = TaskSupervisor::new(CancellationToken::new());
let store = AgeVaultAnchorStore::new(Arc::clone(&vault), supervisor);
let (held_tx, held_rx) = std::sync::mpsc::channel::<()>();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let vault_for_holder = Arc::clone(&vault);
let holder = std::thread::spawn(move || {
let _guard = vault_for_holder.write().unwrap();
held_tx.send(()).unwrap();
release_rx.recv().unwrap(); });
held_rx.recv().unwrap();
let result = tokio::time::timeout(
Duration::from_millis(100),
store.get(AnchorSubsystem::SubagentTranscript, b"whatever"),
)
.await;
assert!(
result.is_err(),
"the external 100ms timeout must fire while the write lock is held — a real \
suspension point must exist for it to race against"
);
release_tx.send(()).unwrap();
holder.join().unwrap();
})
.await;
assert!(
outcome.is_ok(),
"test itself must not hang past its 5s safety-net timeout"
);
}
#[test]
fn get_sync_bounded_times_out_under_contention_instead_of_hanging() {
let dir = tempfile::tempdir().unwrap();
let vault = test_vault(dir.path());
let supervisor = TaskSupervisor::new(CancellationToken::new());
let store = AgeVaultAnchorStore::new(Arc::clone(&vault), supervisor);
let (held_tx, held_rx) = std::sync::mpsc::channel::<()>();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let vault_for_holder = Arc::clone(&vault);
let holder = std::thread::spawn(move || {
let _guard = vault_for_holder.write().unwrap();
held_tx.send(()).unwrap();
release_rx.recv().unwrap();
});
held_rx.recv().unwrap();
let start = std::time::Instant::now();
let result = store.get_sync_bounded(
AnchorSubsystem::SubagentTranscript,
b"whatever",
Duration::from_millis(50),
);
let elapsed = start.elapsed();
assert!(
result.is_err(),
"must fail closed, not hang, under contention"
);
assert!(
elapsed < Duration::from_secs(2),
"must return close to the 50ms bound, not block indefinitely (took {elapsed:?})"
);
release_tx.send(()).unwrap();
holder.join().unwrap();
}
#[test]
fn get_sync_bounded_succeeds_once_contention_clears() {
let dir = tempfile::tempdir().unwrap();
let vault = test_vault(dir.path());
let supervisor = TaskSupervisor::new(CancellationToken::new());
let store = AgeVaultAnchorStore::new(Arc::clone(&vault), supervisor);
let (held_tx, held_rx) = std::sync::mpsc::channel::<()>();
let vault_for_holder = Arc::clone(&vault);
let holder = std::thread::spawn(move || {
let _guard = vault_for_holder.write().unwrap();
std::thread::sleep(Duration::from_millis(50));
held_tx.send(()).unwrap();
});
let result = store.get_sync_bounded(
AnchorSubsystem::SubagentTranscript,
b"whatever",
Duration::from_secs(2),
);
assert!(
result.is_ok(),
"a bound long enough to outlast contention must still succeed"
);
held_rx.recv().unwrap();
holder.join().unwrap();
}
fn sample_anchor(count: u64) -> Anchor {
let key = zeph_common::hash_chain::ChainKey::new([2u8; 32]);
let base = zeph_common::hash_chain::genesis(&key, "d", b"f", 0);
let head = zeph_common::hash_chain::chain_next(&key, &base, b"c");
Anchor::new(0, count, head)
}
#[test]
fn plan_orphan_action_stamps_on_first_observation() {
assert_eq!(plan_orphan_action(None, 1_000), OrphanAction::Stamp);
}
#[test]
fn plan_orphan_action_boundary_grace_minus_one_within_grace_at_grace_reaps() {
let since = 1_000u64;
assert_eq!(
plan_orphan_action(Some(since), since + ORPHAN_REAP_GRACE_MS - 1),
OrphanAction::WithinGrace
);
assert_eq!(
plan_orphan_action(Some(since), since + ORPHAN_REAP_GRACE_MS),
OrphanAction::Reap
);
}
#[test]
fn plan_orphan_action_saturates_on_clock_skew_instead_of_reaping() {
let since = 1_000u64;
assert_eq!(
plan_orphan_action(Some(since), since - 1),
OrphanAction::WithinGrace,
"now < orphaned_since must never underflow or reap"
);
}
fn write_anchor(
vault: &Arc<StdRwLock<AgeVaultProvider>>,
subsystem: AnchorSubsystem,
file_id: &[u8],
anchor: &Anchor,
) {
let mut guard = vault.write().unwrap();
let key = zeph_common::anchor::anchor_key(subsystem, file_id);
let json = serde_json::to_string(anchor).unwrap();
guard.set_secret_mut(key, json, true).unwrap();
guard.save().unwrap();
}
fn read_anchor(
vault: &Arc<StdRwLock<AgeVaultProvider>>,
subsystem: AnchorSubsystem,
file_id: &[u8],
) -> Option<Anchor> {
let guard = vault.read().unwrap();
let key = zeph_common::anchor::anchor_key(subsystem, file_id);
guard.get(&key).map(|v| serde_json::from_str(v).unwrap())
}
#[test]
fn sweep_stamps_then_reaps_orphan_transcript_anchor() {
let dir = tempfile::tempdir().unwrap();
let vault = test_vault(dir.path());
let transcript_dir = dir.path().join("transcripts");
let sessions_dir = dir.path().join("sessions");
std::fs::create_dir_all(&transcript_dir).unwrap();
std::fs::create_dir_all(&sessions_dir).unwrap();
write_anchor(
&vault,
AnchorSubsystem::SubagentTranscript,
b"gone",
&sample_anchor(1),
);
let t0 = 1_000_000u64;
let first = run_anchor_sweep(&vault, &transcript_dir, &sessions_dir, 512, t0).unwrap();
assert_eq!(first.orphans_stamped, 1, "first sweep must stamp, not reap");
assert_eq!(first.orphans_reaped, 0);
assert_eq!(
vault.read().unwrap().list_keys().len(),
1,
"anchor must survive the first sweep"
);
let stamped = read_anchor(&vault, AnchorSubsystem::SubagentTranscript, b"gone").unwrap();
assert_eq!(stamped.orphaned_since, Some(t0));
let after_grace = t0 + ORPHAN_REAP_GRACE_MS;
let second =
run_anchor_sweep(&vault, &transcript_dir, &sessions_dir, 512, after_grace).unwrap();
assert_eq!(
second.orphans_reaped, 1,
"second sweep past grace must reap"
);
assert!(vault.read().unwrap().list_keys().is_empty());
}
#[test]
fn sweep_stamps_then_reaps_orphan_session_log_anchor() {
let dir = tempfile::tempdir().unwrap();
let vault = test_vault(dir.path());
let transcript_dir = dir.path().join("transcripts");
let sessions_dir = dir.path().join("sessions");
std::fs::create_dir_all(&transcript_dir).unwrap();
std::fs::create_dir_all(&sessions_dir).unwrap();
std::fs::create_dir_all(zeph_session::session_dir(&sessions_dir, "legit")).unwrap();
write_anchor(
&vault,
AnchorSubsystem::SessionLog,
b"legit",
&sample_anchor(1),
);
write_anchor(
&vault,
AnchorSubsystem::SessionLog,
b"gone",
&sample_anchor(1),
);
let t0 = 2_000_000u64;
let first = run_anchor_sweep(&vault, &transcript_dir, &sessions_dir, 512, t0).unwrap();
assert_eq!(first.orphans_stamped, 1);
assert_eq!(first.orphans_reaped, 0);
assert!(
read_anchor(&vault, AnchorSubsystem::SessionLog, b"gone")
.unwrap()
.orphaned_since
.is_some()
);
assert!(
read_anchor(&vault, AnchorSubsystem::SessionLog, b"legit")
.unwrap()
.orphaned_since
.is_none(),
"a live sibling session must never be stamped"
);
let after_grace = t0 + ORPHAN_REAP_GRACE_MS;
let second =
run_anchor_sweep(&vault, &transcript_dir, &sessions_dir, 512, after_grace).unwrap();
assert_eq!(second.orphans_reaped, 1);
assert!(
read_anchor(&vault, AnchorSubsystem::SessionLog, b"gone").is_none(),
"the orphan must be gone"
);
assert!(
read_anchor(&vault, AnchorSubsystem::SessionLog, b"legit").is_some(),
"the legitimate session's anchor must survive, undisturbed"
);
}
#[test]
fn sweep_self_heals_when_orphaned_file_reappears_before_grace_elapses() {
let dir = tempfile::tempdir().unwrap();
let vault = test_vault(dir.path());
let transcript_dir = dir.path().join("transcripts");
let sessions_dir = dir.path().join("sessions");
std::fs::create_dir_all(&transcript_dir).unwrap();
std::fs::create_dir_all(&sessions_dir).unwrap();
write_anchor(
&vault,
AnchorSubsystem::SubagentTranscript,
b"flaky",
&sample_anchor(1),
);
let t0 = 1_000_000u64;
let first = run_anchor_sweep(&vault, &transcript_dir, &sessions_dir, 512, t0).unwrap();
assert_eq!(first.orphans_stamped, 1);
std::fs::write(transcript_dir.join("flaky.jsonl"), b"").unwrap();
let heal_time = t0 + 1;
let second =
run_anchor_sweep(&vault, &transcript_dir, &sessions_dir, 512, heal_time).unwrap();
assert_eq!(second.orphans_cleared, 1);
assert_eq!(second.orphans_reaped, 0);
let healed = read_anchor(&vault, AnchorSubsystem::SubagentTranscript, b"flaky").unwrap();
assert_eq!(
healed.orphaned_since, None,
"self-heal must clear the stamp"
);
let far_future = t0 + ORPHAN_REAP_GRACE_MS * 10;
let third =
run_anchor_sweep(&vault, &transcript_dir, &sessions_dir, 512, far_future).unwrap();
assert_eq!(third.orphans_reaped, 0);
assert!(
vault
.read()
.unwrap()
.list_keys()
.contains(&"ZEPH_HISTORY_ANCHOR_SUBAGENT_flaky")
);
std::fs::remove_file(transcript_dir.join("flaky.jsonl")).unwrap();
let re_orphan_time = far_future + 1;
let fourth =
run_anchor_sweep(&vault, &transcript_dir, &sessions_dir, 512, re_orphan_time).unwrap();
assert_eq!(
fourth.orphans_stamped, 1,
"a fresh absence must be stamped again, not treated as already-expired leftover \
from the pre-heal clock"
);
assert_eq!(
fourth.orphans_reaped, 0,
"must not reap immediately — if the clock had merely paused instead of resetting, \
this sweep (already well past t0 + GRACE) would incorrectly reap right away"
);
let re_stamped =
read_anchor(&vault, AnchorSubsystem::SubagentTranscript, b"flaky").unwrap();
assert_eq!(
re_stamped.orphaned_since,
Some(re_orphan_time),
"orphaned_since must restart from the new absence time, not resume counting from t0"
);
let past_new_grace = re_orphan_time + ORPHAN_REAP_GRACE_MS;
let fifth =
run_anchor_sweep(&vault, &transcript_dir, &sessions_dir, 512, past_new_grace).unwrap();
assert_eq!(fifth.orphans_reaped, 1);
assert!(vault.read().unwrap().list_keys().is_empty());
}
#[test]
fn sweep_orphan_reap_boundary_grace_minus_one_not_reaped_grace_reaped() {
let dir = tempfile::tempdir().unwrap();
let vault = test_vault(dir.path());
let transcript_dir = dir.path().join("transcripts");
let sessions_dir = dir.path().join("sessions");
std::fs::create_dir_all(&transcript_dir).unwrap();
std::fs::create_dir_all(&sessions_dir).unwrap();
write_anchor(
&vault,
AnchorSubsystem::SubagentTranscript,
b"gone",
&sample_anchor(1),
);
let t0 = 1_000_000u64;
run_anchor_sweep(&vault, &transcript_dir, &sessions_dir, 512, t0).unwrap();
let just_under = run_anchor_sweep(
&vault,
&transcript_dir,
&sessions_dir,
512,
t0 + ORPHAN_REAP_GRACE_MS - 1,
)
.unwrap();
assert_eq!(
just_under.orphans_reaped, 0,
"must not reap before the grace window elapses"
);
assert_eq!(vault.read().unwrap().list_keys().len(), 1);
let at_grace = run_anchor_sweep(
&vault,
&transcript_dir,
&sessions_dir,
512,
t0 + ORPHAN_REAP_GRACE_MS,
)
.unwrap();
assert_eq!(
at_grace.orphans_reaped, 1,
"must reap once the grace window is reached"
);
assert!(vault.read().unwrap().list_keys().is_empty());
}
#[test]
fn sweep_orphan_reap_saturates_instead_of_underflowing_on_clock_skew() {
let dir = tempfile::tempdir().unwrap();
let vault = test_vault(dir.path());
let transcript_dir = dir.path().join("transcripts");
let sessions_dir = dir.path().join("sessions");
std::fs::create_dir_all(&transcript_dir).unwrap();
std::fs::create_dir_all(&sessions_dir).unwrap();
write_anchor(
&vault,
AnchorSubsystem::SubagentTranscript,
b"gone",
&sample_anchor(1),
);
let t0 = 1_000_000u64;
run_anchor_sweep(&vault, &transcript_dir, &sessions_dir, 512, t0).unwrap();
let report = run_anchor_sweep(&vault, &transcript_dir, &sessions_dir, 512, t0 - 1).unwrap();
assert_eq!(
report.orphans_reaped, 0,
"must fail safe, never reap on a clock regression"
);
assert_eq!(vault.read().unwrap().list_keys().len(), 1);
}
#[test]
fn sweep_keeps_anchor_whose_file_exists() {
let dir = tempfile::tempdir().unwrap();
let vault = test_vault(dir.path());
let transcript_dir = dir.path().join("transcripts");
let sessions_dir = dir.path().join("sessions");
std::fs::create_dir_all(&transcript_dir).unwrap();
std::fs::create_dir_all(&sessions_dir).unwrap();
std::fs::write(transcript_dir.join("alive.jsonl"), b"").unwrap();
{
let mut guard = vault.write().unwrap();
let key =
zeph_common::anchor::anchor_key(AnchorSubsystem::SubagentTranscript, b"alive");
let json = serde_json::to_string(&sample_anchor(1)).unwrap();
guard.set_secret_mut(key, json, true).unwrap();
guard.save().unwrap();
}
let report =
run_anchor_sweep(&vault, &transcript_dir, &sessions_dir, 512, 1_000_000).unwrap();
assert_eq!(report.orphans_reaped, 0);
assert_eq!(vault.read().unwrap().list_keys().len(), 1);
}
#[test]
fn sweep_caps_session_anchors_by_embedded_written_at_not_mtime() {
let dir = tempfile::tempdir().unwrap();
let vault = test_vault(dir.path());
let transcript_dir = dir.path().join("transcripts");
let sessions_dir = dir.path().join("sessions");
std::fs::create_dir_all(&transcript_dir).unwrap();
std::fs::create_dir_all(&sessions_dir).unwrap();
let mut anchors_by_name = Vec::new();
for (name, written_at) in [("s-new", 300u64), ("s-mid", 200), ("s-old", 100)] {
let session_path = zeph_session::session_dir(&sessions_dir, name);
std::fs::create_dir_all(&session_path).unwrap();
let mut anchor = sample_anchor(1);
anchor.written_at = written_at;
anchors_by_name.push((name, anchor));
}
{
let mut guard = vault.write().unwrap();
for (name, anchor) in &anchors_by_name {
let key =
zeph_common::anchor::anchor_key(AnchorSubsystem::SessionLog, name.as_bytes());
let json = serde_json::to_string(anchor).unwrap();
guard.set_secret_mut(key, json, true).unwrap();
}
guard.save().unwrap();
}
let report =
run_anchor_sweep(&vault, &transcript_dir, &sessions_dir, 2, 1_000_000).unwrap();
assert_eq!(report.evicted_for_cap, 1);
let remaining_keys: Vec<String> = vault
.read()
.unwrap()
.list_keys()
.into_iter()
.map(str::to_owned)
.collect();
let old_key = zeph_common::anchor::anchor_key(AnchorSubsystem::SessionLog, b"s-old");
let mid_key = zeph_common::anchor::anchor_key(AnchorSubsystem::SessionLog, b"s-mid");
let new_key = zeph_common::anchor::anchor_key(AnchorSubsystem::SessionLog, b"s-new");
assert!(
!remaining_keys.contains(&old_key),
"the true oldest must be evicted"
);
assert!(remaining_keys.contains(&mid_key));
assert!(remaining_keys.contains(&new_key));
}
#[test]
fn grandfather_set_round_trips_and_merges() {
let a = zeph_durable::ExecutionId::new();
let b = zeph_durable::ExecutionId::new();
let rendered = render_grandfather_set("", &HashSet::from([a]));
let parsed = parse_grandfather_set(&rendered);
assert!(parsed.contains(&a));
let rendered2 = render_grandfather_set(&rendered, &HashSet::from([b]));
let parsed2 = parse_grandfather_set(&rendered2);
assert!(parsed2.contains(&a));
assert!(parsed2.contains(&b));
}
}