use chrono::{DateTime, Utc};
use std::sync::Arc;
use std::time::Duration;
use crate::agent::AgentContext;
use crate::bus::KvStore;
use crate::checkpoint::{ApprovalDecision, RunCheckpoint, CHECKPOINT_BUCKET};
use crate::error::Error;
use crate::ids::ThreadId;
use crate::llm::{FinishReason, Message, Role};
pub(crate) async fn build_suspend_checkpoint(
ctx: &AgentContext,
thread: &ThreadId,
step: u32,
message: &Message,
finish_reason: FinishReason,
max_history_tokens: usize,
) -> Result<RunCheckpoint, Error> {
let pending_tool_calls =
matches!(finish_reason, FinishReason::ToolCalls).then(|| message.tool_calls.clone());
Ok(RunCheckpoint {
run_id: ctx.run_id,
step_index: step,
thread_id: thread.clone(),
messages: ctx
.short_term
.load(thread.clone(), max_history_tokens)
.await?,
pending_tool_calls,
resume_attempted: false,
created_at: Utc::now(),
})
}
pub(crate) async fn persist_checkpoint(
ctx: &AgentContext,
bucket: &str,
checkpoint: &RunCheckpoint,
) -> Result<(), Error> {
let bytes = serde_json::to_vec(checkpoint).map_err(|e| Error::Other {
message: "checkpoint serialize".into(),
source: Some(Box::new(e)),
})?;
ctx.kv
.put(bucket, &checkpoint.run_id.to_string(), bytes.into())
.await?;
Ok(())
}
pub async fn gc_checkpoints(kv: &dyn KvStore, cutoff: DateTime<Utc>) -> Result<u64, Error> {
gc_checkpoints_paged(kv, cutoff, GC_KEY_PAGE).await
}
pub struct CheckpointGcHandle {
task: Option<tokio::task::JoinHandle<()>>,
}
impl Drop for CheckpointGcHandle {
fn drop(&mut self) {
if let Some(task) = self.task.take() {
task.abort();
}
}
}
pub fn spawn_checkpoint_gc(
kv: Arc<dyn KvStore>,
ttl: Duration,
interval: Duration,
) -> CheckpointGcHandle {
let task = tokio::spawn(async move {
loop {
tokio::time::sleep(interval).await;
let Ok(ttl_chrono) = chrono::Duration::from_std(ttl) else {
tracing::warn!(
target: "klieo.checkpoint.gc",
ttl = ?ttl,
"configured ttl is out of range; skipping this sweep"
);
continue;
};
match gc_checkpoints(kv.as_ref(), Utc::now() - ttl_chrono).await {
Ok(reaped) if reaped > 0 => tracing::info!(
target: "klieo.checkpoint.gc",
reaped,
"reaped abandoned suspended-run checkpoints"
),
Ok(_) => {}
Err(err) => tracing::warn!(
target: "klieo.checkpoint.gc",
error = %err,
"checkpoint gc sweep failed; will retry next interval"
),
}
}
});
CheckpointGcHandle { task: Some(task) }
}
const GC_KEY_PAGE: usize = 256;
async fn gc_checkpoints_paged(
kv: &dyn KvStore,
cutoff: DateTime<Utc>,
page_size: usize,
) -> Result<u64, Error> {
let mut reaped = 0u64;
let mut cursor = None;
loop {
let page = kv
.keys_paginated(CHECKPOINT_BUCKET, cursor, page_size)
.await?;
for key in &page.keys {
let Some(checkpoint) = load_checkpoint_for_gc(kv, key).await else {
continue;
};
if checkpoint.created_at > cutoff {
continue;
}
match kv.delete(CHECKPOINT_BUCKET, key).await {
Ok(()) => reaped += 1,
Err(e) => tracing::warn!(
target: "klieo.checkpoint.gc",
operation = "delete",
key = %key,
error = %e,
"checkpoint delete failed; continuing sweep"
),
}
}
match page.next {
Some(c) => cursor = Some(c),
None => break,
}
}
Ok(reaped)
}
async fn load_checkpoint_for_gc(kv: &dyn KvStore, key: &str) -> Option<RunCheckpoint> {
let entry = match kv.get(CHECKPOINT_BUCKET, key).await {
Ok(Some(entry)) => entry,
Ok(None) => return None,
Err(e) => {
tracing::warn!(
target: "klieo.checkpoint.gc",
operation = "read",
key = %key,
error = %e,
"checkpoint read failed; skipping"
);
return None;
}
};
match serde_json::from_slice(&entry.value) {
Ok(checkpoint) => Some(checkpoint),
Err(e) => {
tracing::warn!(
target: "klieo.checkpoint.gc",
operation = "deserialize",
key = %key,
error = %e,
"skipping undeserializable checkpoint entry"
);
None
}
}
}
pub async fn resume_from_checkpoint(
ctx: &AgentContext,
system_prompt: &str,
checkpoint: RunCheckpoint,
decision: ApprovalDecision,
opts: super::RunOptions,
) -> Result<String, Error> {
let thread = checkpoint.thread_id.clone();
let latch = RunCheckpoint {
resume_attempted: true,
..checkpoint.clone()
};
ctx.short_term.clear(thread.clone()).await?;
ctx.short_term
.append_batch(thread.clone(), checkpoint.messages)
.await?;
match (decision, checkpoint.pending_tool_calls) {
(ApprovalDecision::Approved, Some(calls)) => {
dispatch_pending_on_resume(ctx, &thread, &calls, &latch, &opts).await?;
}
(ApprovalDecision::Approved, None) => {}
(ApprovalDecision::Rejected { reason }, _) => {
ctx.short_term
.append(
thread.clone(),
Message {
role: Role::Tool,
content: format!("Human reviewer rejected this step: {reason}"),
tool_calls: vec![],
tool_call_id: None,
},
)
.await?;
}
}
super::run_loop(ctx, system_prompt, &thread, &opts, checkpoint.step_index).await
}
async fn dispatch_pending_on_resume(
ctx: &AgentContext,
thread: &ThreadId,
calls: &[crate::llm::ToolCall],
latch: &RunCheckpoint,
opts: &super::RunOptions,
) -> Result<(), Error> {
let non_idempotent: Vec<String> = calls
.iter()
.filter(|c| !ctx.tools.is_tool_idempotent(&c.name))
.map(|c| c.name.clone())
.collect();
if let Some(bucket) = &opts.checkpoint_kv_bucket {
claim_resume_latch(ctx, bucket, latch, &non_idempotent).await?;
}
super::dispatch_tool_calls(ctx, thread, calls, "resume").await
}
async fn claim_resume_latch(
ctx: &AgentContext,
bucket: &str,
latch: &RunCheckpoint,
non_idempotent: &[String],
) -> Result<(), Error> {
let key = latch.run_id.to_string();
let expected = match ctx.kv.get(bucket, &key).await? {
Some(entry) => {
if checkpoint_is_latched(&entry.value) && !non_idempotent.is_empty() {
return Err(resume_blocked(latch.run_id, non_idempotent));
}
Some(entry.revision)
}
None => None,
};
let bytes = serde_json::to_vec(latch).map_err(|e| Error::wrap("checkpoint serialize", e))?;
match ctx.kv.cas(bucket, &key, bytes.into(), expected).await {
Ok(_) => Ok(()),
Err(crate::error::BusError::CasConflict { .. }) if !non_idempotent.is_empty() => {
Err(resume_blocked(latch.run_id, non_idempotent))
}
Err(crate::error::BusError::CasConflict { .. }) => Ok(()),
Err(e) => Err(Error::Bus(e)),
}
}
fn checkpoint_is_latched(value: &[u8]) -> bool {
serde_json::from_slice::<RunCheckpoint>(value)
.map(|c| c.resume_attempted)
.unwrap_or(true)
}
fn resume_blocked(run_id: crate::ids::RunId, tools: &[String]) -> Error {
tracing::error!(
target: "klieo.checkpoint.resume",
run_id = %run_id,
tools = ?tools,
"resume blocked: non-idempotent pending tool calls cannot be proven un-fired (fail-closed, ADR-045) — operator reconciliation required"
);
Error::ResumeReplayBlocked {
run_id,
tools: tools.to_vec(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bus::{KvEntry, Lease, Revision};
use crate::error::BusError;
use crate::ids::{RunId, ThreadId};
use crate::llm::ToolCall;
use crate::runtime::RunOptions;
use crate::test_utils::{fake_context, fake_kv, FakeLlmClient, FakeLlmStep, FakeToolInvoker};
use async_trait::async_trait;
use bytes::Bytes;
use chrono::Utc;
use std::time::Duration;
struct KeysFailKv;
#[async_trait]
impl crate::bus::KvStore for KeysFailKv {
async fn get(&self, _: &str, _: &str) -> Result<Option<KvEntry>, BusError> {
Err(BusError::Unsupported("get".into()))
}
async fn put(&self, _: &str, _: &str, _: Bytes) -> Result<Revision, BusError> {
Err(BusError::Unsupported("put".into()))
}
async fn cas(
&self,
_: &str,
_: &str,
_: Bytes,
_: Option<Revision>,
) -> Result<Revision, BusError> {
Err(BusError::Unsupported("cas".into()))
}
async fn delete(&self, _: &str, _: &str) -> Result<(), BusError> {
Err(BusError::Unsupported("delete".into()))
}
async fn lease(&self, _: &str, _: &str, _: Duration) -> Result<Lease, BusError> {
Err(BusError::Unsupported("lease".into()))
}
async fn keys(&self, _: &str) -> Result<Vec<String>, BusError> {
Err(BusError::Unsupported("enumerate unavailable".into()))
}
}
struct DeleteFailKv {
value: Bytes,
}
#[async_trait]
impl crate::bus::KvStore for DeleteFailKv {
async fn get(&self, _: &str, _: &str) -> Result<Option<KvEntry>, BusError> {
Ok(Some(KvEntry {
value: self.value.clone(),
revision: 1,
}))
}
async fn put(&self, _: &str, _: &str, _: Bytes) -> Result<Revision, BusError> {
Err(BusError::Unsupported("put".into()))
}
async fn cas(
&self,
_: &str,
_: &str,
_: Bytes,
_: Option<Revision>,
) -> Result<Revision, BusError> {
Err(BusError::Unsupported("cas".into()))
}
async fn delete(&self, _: &str, _: &str) -> Result<(), BusError> {
Err(BusError::Unsupported("delete boom".into()))
}
async fn lease(&self, _: &str, _: &str, _: Duration) -> Result<Lease, BusError> {
Err(BusError::Unsupported("lease".into()))
}
async fn keys(&self, _: &str) -> Result<Vec<String>, BusError> {
Ok(vec!["stale".to_string()])
}
}
struct ConflictKv {
value: Bytes,
}
#[async_trait]
impl crate::bus::KvStore for ConflictKv {
async fn get(&self, _: &str, _: &str) -> Result<Option<KvEntry>, BusError> {
Ok(Some(KvEntry {
value: self.value.clone(),
revision: 1,
}))
}
async fn put(&self, _: &str, _: &str, _: Bytes) -> Result<Revision, BusError> {
Err(BusError::Unsupported("put".into()))
}
async fn cas(
&self,
_: &str,
_: &str,
_: Bytes,
_: Option<Revision>,
) -> Result<Revision, BusError> {
Err(BusError::CasConflict {
expected: 1,
actual: 2,
})
}
async fn delete(&self, _: &str, _: &str) -> Result<(), BusError> {
Err(BusError::Unsupported("delete".into()))
}
async fn lease(&self, _: &str, _: &str, _: Duration) -> Result<Lease, BusError> {
Err(BusError::Unsupported("lease".into()))
}
async fn keys(&self, _: &str) -> Result<Vec<String>, BusError> {
Err(BusError::Unsupported("keys".into()))
}
}
use std::sync::Arc;
#[test]
fn checkpoint_round_trips_through_json() {
let cp = checkpoint_with_pending_tool(ThreadId::new("t-rt"), RunId::new());
let bytes = serde_json::to_vec(&cp).unwrap();
let back: RunCheckpoint = serde_json::from_slice(&bytes).unwrap();
assert_eq!(back.step_index, cp.step_index);
assert_eq!(back.run_id, cp.run_id);
assert_eq!(back.thread_id, cp.thread_id);
assert_eq!(back.messages.len(), cp.messages.len(), "history length");
assert_eq!(back.messages[0].role, cp.messages[0].role);
assert_eq!(back.messages[0].content, cp.messages[0].content);
let restored = back
.pending_tool_calls
.expect("pending tool calls must survive the round-trip");
let original = cp.pending_tool_calls.unwrap();
assert_eq!(restored.len(), original.len());
assert_eq!(restored[0].id, original[0].id);
assert_eq!(restored[0].name, original[0].name);
assert_eq!(restored[0].args, original[0].args);
}
fn echo_tool_invoker() -> Arc<FakeToolInvoker> {
Arc::new(FakeToolInvoker::new().with_tool("echo", "echo back", Ok))
}
fn checkpoint_with_pending_tool(thread: ThreadId, run_id: RunId) -> RunCheckpoint {
RunCheckpoint {
run_id,
step_index: 1,
thread_id: thread,
messages: vec![Message {
role: Role::User,
content: "go".into(),
tool_calls: vec![],
tool_call_id: None,
}],
pending_tool_calls: Some(vec![ToolCall {
id: "tc-1".into(),
name: "echo".into(),
args: serde_json::json!({"x": 1}),
}]),
resume_attempted: false,
created_at: Utc::now(),
}
}
fn checkpoint_no_pending(thread: ThreadId, run_id: RunId) -> RunCheckpoint {
RunCheckpoint {
run_id,
step_index: 1,
thread_id: thread,
messages: vec![Message {
role: Role::User,
content: "go".into(),
tool_calls: vec![],
tool_call_id: None,
}],
pending_tool_calls: None,
resume_attempted: false,
created_at: Utc::now(),
}
}
#[tokio::test]
async fn resume_approved_dispatches_pending_then_completes() {
let mut ctx = fake_context("resume-test");
ctx.llm =
Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
ctx.kv = fake_kv();
ctx.tools = echo_tool_invoker();
let thread = ThreadId::new("t-resume-approved");
let cp = checkpoint_with_pending_tool(thread.clone(), ctx.run_id);
let out = resume_from_checkpoint(
&ctx,
"sys",
cp,
ApprovalDecision::Approved,
RunOptions::default(),
)
.await
.unwrap();
assert_eq!(out, "done");
let history = ctx.short_term.load(thread, 8192).await.unwrap();
let tool_msgs: Vec<_> = history.iter().filter(|m| m.role == Role::Tool).collect();
assert_eq!(
tool_msgs.len(),
1,
"dispatched tool must leave a Role::Tool message"
);
assert_eq!(tool_msgs[0].tool_call_id.as_deref(), Some("tc-1"));
}
#[tokio::test]
async fn resume_approved_without_pending_calls_completes_and_injects_no_tool_message() {
let mut ctx = fake_context("resume-approved-none");
ctx.llm =
Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
ctx.kv = fake_kv();
let thread = ThreadId::new("t-resume-approved-none");
let cp = checkpoint_no_pending(thread.clone(), ctx.run_id);
let out = resume_from_checkpoint(
&ctx,
"sys",
cp,
ApprovalDecision::Approved,
RunOptions::default(),
)
.await
.unwrap();
assert_eq!(out, "done");
let history = ctx.short_term.load(thread, 8192).await.unwrap();
let tool_msgs = history.iter().filter(|m| m.role == Role::Tool).count();
assert_eq!(
tool_msgs, 0,
"approving a step with no pending tool calls must inject nothing"
);
}
#[tokio::test]
async fn resume_rejected_injects_tool_message_then_completes() {
let mut ctx = fake_context("resume-reject");
ctx.llm =
Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
ctx.kv = fake_kv();
let thread = ThreadId::new("t-resume-rejected");
let cp = checkpoint_no_pending(thread.clone(), ctx.run_id);
let out = resume_from_checkpoint(
&ctx,
"sys",
cp,
ApprovalDecision::Rejected {
reason: "no".into(),
},
RunOptions::default(),
)
.await
.unwrap();
assert_eq!(out, "done");
let history = ctx.short_term.load(thread, 8192).await.unwrap();
let rejection: Vec<_> = history
.iter()
.filter(|m| m.role == Role::Tool && m.content.contains("no"))
.collect();
assert_eq!(rejection.len(), 1, "rejected message must be appended");
assert!(rejection[0]
.content
.contains("Human reviewer rejected this step: no"));
}
#[tokio::test]
async fn resume_rejected_with_pending_calls_does_not_dispatch_them() {
let mut ctx = fake_context("resume-reject-pending");
ctx.llm =
Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
ctx.kv = fake_kv();
ctx.tools = echo_tool_invoker();
let thread = ThreadId::new("t-resume-reject-pending");
let cp = checkpoint_with_pending_tool(thread.clone(), ctx.run_id);
let out = resume_from_checkpoint(
&ctx,
"sys",
cp,
ApprovalDecision::Rejected {
reason: "blocked".into(),
},
RunOptions::default(),
)
.await
.unwrap();
assert_eq!(out, "done");
let history = ctx.short_term.load(thread, 8192).await.unwrap();
assert!(
!history
.iter()
.any(|m| m.tool_call_id.as_deref() == Some("tc-1")),
"a rejected step must NOT dispatch its pending tool calls"
);
assert!(
history
.iter()
.any(|m| m.role == Role::Tool && m.content.contains("blocked")),
"the rejection reason must be fed back to the model"
);
}
async fn seed_latched(ctx: &AgentContext, thread: &ThreadId) {
let mut latched = checkpoint_with_pending_tool(thread.clone(), ctx.run_id);
latched.resume_attempted = true;
ctx.kv
.put(
CHECKPOINT_BUCKET,
&ctx.run_id.to_string(),
serde_json::to_vec(&latched).unwrap().into(),
)
.await
.unwrap();
}
#[tokio::test]
async fn resume_retry_with_non_idempotent_pending_fails_closed() {
let mut ctx = fake_context("resume-retry-nonidem");
ctx.llm =
Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
ctx.kv = fake_kv();
ctx.tools = echo_tool_invoker();
let thread = ThreadId::new("t-retry-nonidem");
seed_latched(&ctx, &thread).await; let cp = checkpoint_with_pending_tool(thread.clone(), ctx.run_id);
let err = resume_from_checkpoint(
&ctx,
"sys",
cp,
ApprovalDecision::Approved,
RunOptions::default().with_checkpoint_bucket(CHECKPOINT_BUCKET),
)
.await
.unwrap_err();
match err {
Error::ResumeReplayBlocked { tools, .. } => assert_eq!(
tools,
vec!["echo".to_string()],
"the refused non-idempotent tool must be named"
),
other => panic!("expected ResumeReplayBlocked, got {other:?}"),
}
let history = ctx.short_term.load(thread, 8192).await.unwrap();
assert!(
!history
.iter()
.any(|m| m.tool_call_id.as_deref() == Some("tc-1")),
"a fail-closed resume must NOT re-dispatch the non-idempotent pending call"
);
}
#[tokio::test]
async fn resume_retry_with_idempotent_pending_redispatches() {
let mut ctx = fake_context("resume-retry-idem");
ctx.llm =
Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
ctx.kv = fake_kv();
ctx.tools = Arc::new(FakeToolInvoker::new().with_idempotent_tool("echo", "echo back", Ok));
let thread = ThreadId::new("t-retry-idem");
seed_latched(&ctx, &thread).await;
let cp = checkpoint_with_pending_tool(thread.clone(), ctx.run_id);
let out = resume_from_checkpoint(
&ctx,
"sys",
cp,
ApprovalDecision::Approved,
RunOptions::default().with_checkpoint_bucket(CHECKPOINT_BUCKET),
)
.await
.unwrap();
assert_eq!(out, "done");
let history = ctx.short_term.load(thread, 8192).await.unwrap();
assert!(
history
.iter()
.any(|m| m.tool_call_id.as_deref() == Some("tc-1")),
"an idempotent tool is safe to re-dispatch even after a prior resume latch"
);
}
#[tokio::test]
async fn concurrent_resume_losing_the_cas_fails_closed() {
let mut ctx = fake_context("resume-cas-conflict");
ctx.llm =
Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
ctx.tools = echo_tool_invoker();
let thread = ThreadId::new("t-cas-conflict");
let unlatched =
serde_json::to_vec(&checkpoint_with_pending_tool(thread.clone(), ctx.run_id))
.unwrap()
.into();
ctx.kv = Arc::new(ConflictKv { value: unlatched });
let err = resume_from_checkpoint(
&ctx,
"sys",
checkpoint_with_pending_tool(thread, ctx.run_id),
ApprovalDecision::Approved,
RunOptions::default().with_checkpoint_bucket(CHECKPOINT_BUCKET),
)
.await
.unwrap_err();
assert!(
matches!(err, Error::ResumeReplayBlocked { .. }),
"losing the latch CAS to a concurrent resume must fail closed, got {err:?}"
);
}
#[tokio::test]
async fn first_resume_latches_attempt_before_dispatch() {
let mut ctx = fake_context("resume-latch");
ctx.llm =
Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
ctx.kv = fake_kv();
ctx.tools = echo_tool_invoker();
let thread = ThreadId::new("t-latch");
let run_id = ctx.run_id;
let cp = checkpoint_with_pending_tool(thread, run_id);
resume_from_checkpoint(
&ctx,
"sys",
cp,
ApprovalDecision::Approved,
RunOptions::default().with_checkpoint_bucket(CHECKPOINT_BUCKET),
)
.await
.unwrap();
let entry = ctx
.kv
.get(CHECKPOINT_BUCKET, &run_id.to_string())
.await
.unwrap()
.expect("first resume must persist the latched checkpoint");
let latched: RunCheckpoint = serde_json::from_slice(&entry.value).unwrap();
assert!(
latched.resume_attempted,
"first resume must latch resume_attempted=true before dispatch, so a retry is recognised"
);
}
#[test]
fn corrupt_checkpoint_bytes_treated_as_latched() {
assert!(
checkpoint_is_latched(b"not-json"),
"an unparseable stored checkpoint must read as latched — refuse rather than risk a re-fire"
);
let unlatched = serde_json::to_vec(&checkpoint_no_pending(
ThreadId::new("t-parse"),
RunId::new(),
))
.unwrap();
assert!(
!checkpoint_is_latched(&unlatched),
"a well-formed un-latched checkpoint must read as not latched"
);
}
#[tokio::test]
async fn bucketless_resume_skips_latch_and_dispatches() {
let mut ctx = fake_context("resume-no-bucket");
ctx.llm =
Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
ctx.kv = fake_kv();
ctx.tools = echo_tool_invoker();
let thread = ThreadId::new("t-no-bucket");
let run_id = ctx.run_id;
let cp = checkpoint_with_pending_tool(thread.clone(), run_id);
let out = resume_from_checkpoint(
&ctx,
"sys",
cp,
ApprovalDecision::Approved,
RunOptions::default(),
)
.await
.unwrap();
assert_eq!(out, "done");
let history = ctx.short_term.load(thread, 8192).await.unwrap();
assert!(
history
.iter()
.any(|m| m.tool_call_id.as_deref() == Some("tc-1")),
"a within-process resume with no bucket is pre-dispatch-safe and dispatches directly"
);
assert!(
ctx.kv
.get(CHECKPOINT_BUCKET, &run_id.to_string())
.await
.unwrap()
.is_none(),
"no bucket means no latch is persisted"
);
}
#[tokio::test]
async fn concurrent_cas_conflict_with_idempotent_batch_proceeds() {
let mut ctx = fake_context("resume-conflict-idem");
ctx.llm =
Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
ctx.tools = Arc::new(FakeToolInvoker::new().with_idempotent_tool("echo", "echo back", Ok));
let thread = ThreadId::new("t-conflict-idem");
let unlatched =
serde_json::to_vec(&checkpoint_with_pending_tool(thread.clone(), ctx.run_id))
.unwrap()
.into();
ctx.kv = Arc::new(ConflictKv { value: unlatched });
let out = resume_from_checkpoint(
&ctx,
"sys",
checkpoint_with_pending_tool(thread.clone(), ctx.run_id),
ApprovalDecision::Approved,
RunOptions::default().with_checkpoint_bucket(CHECKPOINT_BUCKET),
)
.await
.unwrap();
assert_eq!(out, "done");
let history = ctx.short_term.load(thread, 8192).await.unwrap();
assert!(
history
.iter()
.any(|m| m.tool_call_id.as_deref() == Some("tc-1")),
"an idempotent tool proceeds even when it loses the latch CAS"
);
}
#[test]
fn legacy_checkpoint_without_resume_attempted_defaults_false() {
let run_id = RunId::new();
let legacy = format!(
r#"{{"run_id":"{run_id}","step_index":1,"thread_id":"t-legacy","messages":[],"pending_tool_calls":null,"created_at":"2026-06-12T00:00:00Z"}}"#
);
let back: RunCheckpoint = serde_json::from_str(&legacy).unwrap();
assert!(!back.resume_attempted);
}
async fn seed_checkpoint(
kv: &Arc<dyn crate::bus::KvStore>,
run_id: RunId,
created_at: chrono::DateTime<Utc>,
) {
let key = run_id.to_string();
let mut cp = checkpoint_with_pending_tool(ThreadId::new("t-gc"), run_id);
cp.created_at = created_at;
let bytes = serde_json::to_vec(&cp).unwrap();
kv.put(CHECKPOINT_BUCKET, &key, bytes.into()).await.unwrap();
}
#[tokio::test]
async fn gc_checkpoints_reaps_only_entries_at_or_before_cutoff() {
let kv = fake_kv();
let now = Utc::now();
let cutoff = now - chrono::Duration::hours(1);
let stale = RunId::new();
let at_cutoff = RunId::new();
let fresh = RunId::new();
let stale_key = stale.to_string();
let at_cutoff_key = at_cutoff.to_string();
let fresh_key = fresh.to_string();
seed_checkpoint(&kv, stale, now - chrono::Duration::hours(2)).await;
seed_checkpoint(&kv, at_cutoff, cutoff).await;
seed_checkpoint(&kv, fresh, now).await;
let reaped = gc_checkpoints(kv.as_ref(), cutoff).await.unwrap();
assert_eq!(
reaped, 2,
"the stale checkpoint and the one exactly at the cutoff are both reaped (bound is <=)"
);
assert!(
kv.get(CHECKPOINT_BUCKET, &stale_key)
.await
.unwrap()
.is_none(),
"the stale checkpoint is deleted"
);
assert!(
kv.get(CHECKPOINT_BUCKET, &at_cutoff_key)
.await
.unwrap()
.is_none(),
"a checkpoint whose created_at equals the cutoff is reaped — the bound is inclusive"
);
assert!(
kv.get(CHECKPOINT_BUCKET, &fresh_key)
.await
.unwrap()
.is_some(),
"a checkpoint newer than the cutoff must survive the sweep"
);
}
#[tokio::test]
async fn gc_checkpoints_paged_reaps_eligible_across_pages() {
let kv = fake_kv();
let now = Utc::now();
let cutoff = now;
let stale = now - chrono::Duration::hours(1);
let ids: Vec<RunId> = (0..5).map(|_| RunId::new()).collect();
for id in &ids {
seed_checkpoint(&kv, *id, stale).await;
}
let reaped = gc_checkpoints_paged(kv.as_ref(), cutoff, 2).await.unwrap();
assert_eq!(reaped, 5);
for id in &ids {
assert!(
kv.get(CHECKPOINT_BUCKET, &id.to_string())
.await
.unwrap()
.is_none(),
"every eligible checkpoint across all pages is reaped"
);
}
}
#[tokio::test]
async fn gc_checkpoints_skips_undeserializable_entry_without_failing() {
let kv = fake_kv();
let now = Utc::now();
let stale = RunId::new();
let stale_key = stale.to_string();
seed_checkpoint(&kv, stale, now - chrono::Duration::hours(2)).await;
kv.put(
CHECKPOINT_BUCKET,
"not-a-checkpoint",
b"junk".to_vec().into(),
)
.await
.unwrap();
let reaped = gc_checkpoints(kv.as_ref(), now).await.unwrap();
assert_eq!(reaped, 1, "the valid stale checkpoint is still reaped");
assert!(
kv.get(CHECKPOINT_BUCKET, &stale_key)
.await
.unwrap()
.is_none(),
"the stale checkpoint is gone"
);
assert!(
kv.get(CHECKPOINT_BUCKET, "not-a-checkpoint")
.await
.unwrap()
.is_some(),
"an undeserializable entry is skipped, not deleted — a foreign value cannot stall the sweep"
);
}
#[tokio::test]
async fn gc_checkpoints_empty_bucket_is_zero() {
let kv = fake_kv();
let reaped = gc_checkpoints(kv.as_ref(), Utc::now()).await.unwrap();
assert_eq!(reaped, 0, "an empty bucket reaps nothing");
}
#[tokio::test]
async fn gc_checkpoints_propagates_enumerate_failure() {
let kv = KeysFailKv;
let err = gc_checkpoints(&kv, Utc::now()).await.unwrap_err();
assert!(
matches!(err, Error::Bus(BusError::Unsupported(_))),
"a keys() failure must propagate — the sweep cannot enumerate, so it must surface the error, not silently report zero reaped"
);
}
#[tokio::test]
async fn gc_checkpoints_skips_entry_whose_delete_fails() {
let mut cp = checkpoint_with_pending_tool(ThreadId::new("t-del"), RunId::new());
cp.created_at = Utc::now() - chrono::Duration::hours(1);
let kv = DeleteFailKv {
value: Bytes::from(serde_json::to_vec(&cp).unwrap()),
};
let reaped = gc_checkpoints(&kv, Utc::now()).await.unwrap();
assert_eq!(
reaped, 0,
"a stale checkpoint whose delete fails is logged and skipped, not counted; the sweep still returns Ok"
);
}
#[tokio::test]
async fn spawn_checkpoint_gc_reaps_stale_and_keeps_fresh() {
let kv = fake_kv();
let stale = RunId::new();
let fresh = RunId::new();
seed_checkpoint(&kv, stale, Utc::now() - chrono::Duration::hours(2)).await;
seed_checkpoint(&kv, fresh, Utc::now()).await;
let handle = spawn_checkpoint_gc(
Arc::clone(&kv),
Duration::from_secs(3600),
Duration::from_millis(5),
);
tokio::time::sleep(Duration::from_millis(80)).await;
assert!(
kv.get(CHECKPOINT_BUCKET, &stale.to_string())
.await
.unwrap()
.is_none(),
"the spawned gc must reap a checkpoint older than the ttl"
);
assert!(
kv.get(CHECKPOINT_BUCKET, &fresh.to_string())
.await
.unwrap()
.is_some(),
"a checkpoint within the ttl must survive"
);
drop(handle);
}
#[tokio::test]
async fn checkpoint_gc_handle_drop_stops_reaping() {
let kv = fake_kv();
let stale = RunId::new();
seed_checkpoint(&kv, stale, Utc::now() - chrono::Duration::hours(2)).await;
let handle = spawn_checkpoint_gc(
Arc::clone(&kv),
Duration::from_secs(3600),
Duration::from_millis(5),
);
drop(handle);
tokio::time::sleep(Duration::from_millis(80)).await;
assert!(
kv.get(CHECKPOINT_BUCKET, &stale.to_string())
.await
.unwrap()
.is_some(),
"dropping the handle aborts the task, so the stale checkpoint is never reaped"
);
}
#[tokio::test]
async fn spawn_checkpoint_gc_with_out_of_range_ttl_skips_sweep() {
let kv = fake_kv();
let stale = RunId::new();
seed_checkpoint(&kv, stale, Utc::now() - chrono::Duration::hours(2)).await;
let handle = spawn_checkpoint_gc(Arc::clone(&kv), Duration::MAX, Duration::from_millis(5));
tokio::time::sleep(Duration::from_millis(80)).await;
assert!(
kv.get(CHECKPOINT_BUCKET, &stale.to_string())
.await
.unwrap()
.is_some(),
"an out-of-range ttl must skip the sweep, never reap against a degenerate cutoff"
);
drop(handle);
}
}