1use chrono::{DateTime, Utc};
6use std::sync::Arc;
7use std::time::Duration;
8
9use crate::agent::AgentContext;
10use crate::bus::KvStore;
11use crate::checkpoint::{ApprovalDecision, RunCheckpoint, CHECKPOINT_BUCKET};
12use crate::error::Error;
13use crate::ids::ThreadId;
14use crate::llm::{FinishReason, Message, Role};
15
16pub(crate) async fn build_suspend_checkpoint(
19 ctx: &AgentContext,
20 thread: &ThreadId,
21 step: u32,
22 message: &Message,
23 finish_reason: FinishReason,
24 max_history_tokens: usize,
25) -> Result<RunCheckpoint, Error> {
26 let pending_tool_calls =
27 matches!(finish_reason, FinishReason::ToolCalls).then(|| message.tool_calls.clone());
28 Ok(RunCheckpoint {
29 run_id: ctx.run_id,
30 step_index: step,
31 thread_id: thread.clone(),
32 messages: ctx
33 .short_term
34 .load(thread.clone(), max_history_tokens)
35 .await?,
36 pending_tool_calls,
37 resume_attempted: false,
38 created_at: Utc::now(),
39 })
40}
41
42pub(crate) async fn persist_checkpoint(
47 ctx: &AgentContext,
48 bucket: &str,
49 checkpoint: &RunCheckpoint,
50) -> Result<(), Error> {
51 let bytes = serde_json::to_vec(checkpoint).map_err(|e| Error::Other {
52 message: "checkpoint serialize".into(),
53 source: Some(Box::new(e)),
54 })?;
55 ctx.kv
56 .put(bucket, &checkpoint.run_id.to_string(), bytes.into())
57 .await?;
58 Ok(())
59}
60
61pub async fn gc_checkpoints(kv: &dyn KvStore, cutoff: DateTime<Utc>) -> Result<u64, Error> {
78 gc_checkpoints_paged(kv, cutoff, GC_KEY_PAGE).await
79}
80
81pub struct CheckpointGcHandle {
83 task: Option<tokio::task::JoinHandle<()>>,
84}
85
86impl Drop for CheckpointGcHandle {
87 fn drop(&mut self) {
88 if let Some(task) = self.task.take() {
89 task.abort();
90 }
91 }
92}
93
94pub fn spawn_checkpoint_gc(
103 kv: Arc<dyn KvStore>,
104 ttl: Duration,
105 interval: Duration,
106) -> CheckpointGcHandle {
107 let task = tokio::spawn(async move {
108 loop {
109 tokio::time::sleep(interval).await;
110 let Ok(ttl_chrono) = chrono::Duration::from_std(ttl) else {
111 tracing::warn!(
112 target: "klieo.checkpoint.gc",
113 ttl = ?ttl,
114 "configured ttl is out of range; skipping this sweep"
115 );
116 continue;
117 };
118 match gc_checkpoints(kv.as_ref(), Utc::now() - ttl_chrono).await {
119 Ok(reaped) if reaped > 0 => tracing::info!(
120 target: "klieo.checkpoint.gc",
121 reaped,
122 "reaped abandoned suspended-run checkpoints"
123 ),
124 Ok(_) => {}
125 Err(err) => tracing::warn!(
126 target: "klieo.checkpoint.gc",
127 error = %err,
128 "checkpoint gc sweep failed; will retry next interval"
129 ),
130 }
131 }
132 });
133 CheckpointGcHandle { task: Some(task) }
134}
135
136const GC_KEY_PAGE: usize = 256;
141
142async fn gc_checkpoints_paged(
145 kv: &dyn KvStore,
146 cutoff: DateTime<Utc>,
147 page_size: usize,
148) -> Result<u64, Error> {
149 let mut reaped = 0u64;
150 let mut cursor = None;
151 loop {
152 let page = kv
153 .keys_paginated(CHECKPOINT_BUCKET, cursor, page_size)
154 .await?;
155 for key in &page.keys {
156 let Some(checkpoint) = load_checkpoint_for_gc(kv, key).await else {
157 continue;
158 };
159 if checkpoint.created_at > cutoff {
160 continue;
161 }
162 match kv.delete(CHECKPOINT_BUCKET, key).await {
163 Ok(()) => reaped += 1,
164 Err(e) => tracing::warn!(
165 target: "klieo.checkpoint.gc",
166 operation = "delete",
167 key = %key,
168 error = %e,
169 "checkpoint delete failed; continuing sweep"
170 ),
171 }
172 }
173 match page.next {
174 Some(c) => cursor = Some(c),
175 None => break,
176 }
177 }
178 Ok(reaped)
179}
180
181async fn load_checkpoint_for_gc(kv: &dyn KvStore, key: &str) -> Option<RunCheckpoint> {
186 let entry = match kv.get(CHECKPOINT_BUCKET, key).await {
187 Ok(Some(entry)) => entry,
188 Ok(None) => return None,
189 Err(e) => {
190 tracing::warn!(
191 target: "klieo.checkpoint.gc",
192 operation = "read",
193 key = %key,
194 error = %e,
195 "checkpoint read failed; skipping"
196 );
197 return None;
198 }
199 };
200 match serde_json::from_slice(&entry.value) {
201 Ok(checkpoint) => Some(checkpoint),
202 Err(e) => {
203 tracing::warn!(
204 target: "klieo.checkpoint.gc",
205 operation = "deserialize",
206 key = %key,
207 error = %e,
208 "skipping undeserializable checkpoint entry"
209 );
210 None
211 }
212 }
213}
214
215pub async fn resume_from_checkpoint(
228 ctx: &AgentContext,
229 system_prompt: &str,
230 checkpoint: RunCheckpoint,
231 decision: ApprovalDecision,
232 opts: super::RunOptions,
233) -> Result<String, Error> {
234 let thread = checkpoint.thread_id.clone();
235 let latch = RunCheckpoint {
236 resume_attempted: true,
237 ..checkpoint.clone()
238 };
239 ctx.short_term.clear(thread.clone()).await?;
240 ctx.short_term
241 .append_batch(thread.clone(), checkpoint.messages)
242 .await?;
243 match (decision, checkpoint.pending_tool_calls) {
244 (ApprovalDecision::Approved, Some(calls)) => {
245 dispatch_pending_on_resume(ctx, &thread, &calls, &latch, &opts).await?;
246 }
247 (ApprovalDecision::Approved, None) => {}
248 (ApprovalDecision::Rejected { reason }, _) => {
249 ctx.short_term
250 .append(
251 thread.clone(),
252 Message {
253 role: Role::Tool,
254 content: format!("Human reviewer rejected this step: {reason}"),
255 tool_calls: vec![],
256 tool_call_id: None,
257 },
258 )
259 .await?;
260 }
261 }
262 super::run_loop(ctx, system_prompt, &thread, &opts, checkpoint.step_index).await
263}
264
265async fn dispatch_pending_on_resume(
270 ctx: &AgentContext,
271 thread: &ThreadId,
272 calls: &[crate::llm::ToolCall],
273 latch: &RunCheckpoint,
274 opts: &super::RunOptions,
275) -> Result<(), Error> {
276 let non_idempotent: Vec<String> = calls
277 .iter()
278 .filter(|c| !ctx.tools.is_tool_idempotent(&c.name))
279 .map(|c| c.name.clone())
280 .collect();
281 if let Some(bucket) = &opts.checkpoint_kv_bucket {
282 claim_resume_latch(ctx, bucket, latch, &non_idempotent).await?;
283 }
284 super::dispatch_tool_calls(ctx, thread, calls, "resume").await
285}
286
287async fn claim_resume_latch(
293 ctx: &AgentContext,
294 bucket: &str,
295 latch: &RunCheckpoint,
296 non_idempotent: &[String],
297) -> Result<(), Error> {
298 let key = latch.run_id.to_string();
299 let expected = match ctx.kv.get(bucket, &key).await? {
300 Some(entry) => {
301 if checkpoint_is_latched(&entry.value) && !non_idempotent.is_empty() {
302 return Err(resume_blocked(latch.run_id, non_idempotent));
303 }
304 Some(entry.revision)
305 }
306 None => None,
307 };
308 let bytes = serde_json::to_vec(latch).map_err(|e| Error::wrap("checkpoint serialize", e))?;
309 match ctx.kv.cas(bucket, &key, bytes.into(), expected).await {
310 Ok(_) => Ok(()),
311 Err(crate::error::BusError::CasConflict { .. }) if !non_idempotent.is_empty() => {
312 Err(resume_blocked(latch.run_id, non_idempotent))
313 }
314 Err(crate::error::BusError::CasConflict { .. }) => Ok(()),
315 Err(e) => Err(Error::Bus(e)),
316 }
317}
318
319fn checkpoint_is_latched(value: &[u8]) -> bool {
323 serde_json::from_slice::<RunCheckpoint>(value)
324 .map(|c| c.resume_attempted)
325 .unwrap_or(true)
326}
327
328fn resume_blocked(run_id: crate::ids::RunId, tools: &[String]) -> Error {
329 tracing::error!(
330 target: "klieo.checkpoint.resume",
331 run_id = %run_id,
332 tools = ?tools,
333 "resume blocked: non-idempotent pending tool calls cannot be proven un-fired (fail-closed, ADR-045) — operator reconciliation required"
334 );
335 Error::ResumeReplayBlocked {
336 run_id,
337 tools: tools.to_vec(),
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use crate::bus::{KvEntry, Lease, Revision};
345 use crate::error::BusError;
346 use crate::ids::{RunId, ThreadId};
347 use crate::llm::ToolCall;
348 use crate::runtime::RunOptions;
349 use crate::test_utils::{fake_context, fake_kv, FakeLlmClient, FakeLlmStep, FakeToolInvoker};
350 use async_trait::async_trait;
351 use bytes::Bytes;
352 use chrono::Utc;
353 use std::time::Duration;
354
355 struct KeysFailKv;
356
357 #[async_trait]
358 impl crate::bus::KvStore for KeysFailKv {
359 async fn get(&self, _: &str, _: &str) -> Result<Option<KvEntry>, BusError> {
360 Err(BusError::Unsupported("get".into()))
361 }
362 async fn put(&self, _: &str, _: &str, _: Bytes) -> Result<Revision, BusError> {
363 Err(BusError::Unsupported("put".into()))
364 }
365 async fn cas(
366 &self,
367 _: &str,
368 _: &str,
369 _: Bytes,
370 _: Option<Revision>,
371 ) -> Result<Revision, BusError> {
372 Err(BusError::Unsupported("cas".into()))
373 }
374 async fn delete(&self, _: &str, _: &str) -> Result<(), BusError> {
375 Err(BusError::Unsupported("delete".into()))
376 }
377 async fn lease(&self, _: &str, _: &str, _: Duration) -> Result<Lease, BusError> {
378 Err(BusError::Unsupported("lease".into()))
379 }
380 async fn keys(&self, _: &str) -> Result<Vec<String>, BusError> {
381 Err(BusError::Unsupported("enumerate unavailable".into()))
382 }
383 }
384
385 struct DeleteFailKv {
386 value: Bytes,
387 }
388
389 #[async_trait]
390 impl crate::bus::KvStore for DeleteFailKv {
391 async fn get(&self, _: &str, _: &str) -> Result<Option<KvEntry>, BusError> {
392 Ok(Some(KvEntry {
393 value: self.value.clone(),
394 revision: 1,
395 }))
396 }
397 async fn put(&self, _: &str, _: &str, _: Bytes) -> Result<Revision, BusError> {
398 Err(BusError::Unsupported("put".into()))
399 }
400 async fn cas(
401 &self,
402 _: &str,
403 _: &str,
404 _: Bytes,
405 _: Option<Revision>,
406 ) -> Result<Revision, BusError> {
407 Err(BusError::Unsupported("cas".into()))
408 }
409 async fn delete(&self, _: &str, _: &str) -> Result<(), BusError> {
410 Err(BusError::Unsupported("delete boom".into()))
411 }
412 async fn lease(&self, _: &str, _: &str, _: Duration) -> Result<Lease, BusError> {
413 Err(BusError::Unsupported("lease".into()))
414 }
415 async fn keys(&self, _: &str) -> Result<Vec<String>, BusError> {
416 Ok(vec!["stale".to_string()])
417 }
418 }
419
420 struct ConflictKv {
424 value: Bytes,
425 }
426
427 #[async_trait]
428 impl crate::bus::KvStore for ConflictKv {
429 async fn get(&self, _: &str, _: &str) -> Result<Option<KvEntry>, BusError> {
430 Ok(Some(KvEntry {
431 value: self.value.clone(),
432 revision: 1,
433 }))
434 }
435 async fn put(&self, _: &str, _: &str, _: Bytes) -> Result<Revision, BusError> {
436 Err(BusError::Unsupported("put".into()))
437 }
438 async fn cas(
439 &self,
440 _: &str,
441 _: &str,
442 _: Bytes,
443 _: Option<Revision>,
444 ) -> Result<Revision, BusError> {
445 Err(BusError::CasConflict {
446 expected: 1,
447 actual: 2,
448 })
449 }
450 async fn delete(&self, _: &str, _: &str) -> Result<(), BusError> {
451 Err(BusError::Unsupported("delete".into()))
452 }
453 async fn lease(&self, _: &str, _: &str, _: Duration) -> Result<Lease, BusError> {
454 Err(BusError::Unsupported("lease".into()))
455 }
456 async fn keys(&self, _: &str) -> Result<Vec<String>, BusError> {
457 Err(BusError::Unsupported("keys".into()))
458 }
459 }
460 use std::sync::Arc;
461
462 #[test]
463 fn checkpoint_round_trips_through_json() {
464 let cp = checkpoint_with_pending_tool(ThreadId::new("t-rt"), RunId::new());
465 let bytes = serde_json::to_vec(&cp).unwrap();
466 let back: RunCheckpoint = serde_json::from_slice(&bytes).unwrap();
467 assert_eq!(back.step_index, cp.step_index);
468 assert_eq!(back.run_id, cp.run_id);
469 assert_eq!(back.thread_id, cp.thread_id);
470
471 assert_eq!(back.messages.len(), cp.messages.len(), "history length");
472 assert_eq!(back.messages[0].role, cp.messages[0].role);
473 assert_eq!(back.messages[0].content, cp.messages[0].content);
474
475 let restored = back
476 .pending_tool_calls
477 .expect("pending tool calls must survive the round-trip");
478 let original = cp.pending_tool_calls.unwrap();
479 assert_eq!(restored.len(), original.len());
480 assert_eq!(restored[0].id, original[0].id);
481 assert_eq!(restored[0].name, original[0].name);
482 assert_eq!(restored[0].args, original[0].args);
483 }
484
485 fn echo_tool_invoker() -> Arc<FakeToolInvoker> {
486 Arc::new(FakeToolInvoker::new().with_tool("echo", "echo back", Ok))
487 }
488
489 fn checkpoint_with_pending_tool(thread: ThreadId, run_id: RunId) -> RunCheckpoint {
490 RunCheckpoint {
491 run_id,
492 step_index: 1,
493 thread_id: thread,
494 messages: vec![Message {
495 role: Role::User,
496 content: "go".into(),
497 tool_calls: vec![],
498 tool_call_id: None,
499 }],
500 pending_tool_calls: Some(vec![ToolCall {
501 id: "tc-1".into(),
502 name: "echo".into(),
503 args: serde_json::json!({"x": 1}),
504 }]),
505 resume_attempted: false,
506 created_at: Utc::now(),
507 }
508 }
509
510 fn checkpoint_no_pending(thread: ThreadId, run_id: RunId) -> RunCheckpoint {
511 RunCheckpoint {
512 run_id,
513 step_index: 1,
514 thread_id: thread,
515 messages: vec![Message {
516 role: Role::User,
517 content: "go".into(),
518 tool_calls: vec![],
519 tool_call_id: None,
520 }],
521 pending_tool_calls: None,
522 resume_attempted: false,
523 created_at: Utc::now(),
524 }
525 }
526
527 #[tokio::test]
528 async fn resume_approved_dispatches_pending_then_completes() {
529 let mut ctx = fake_context("resume-test");
530 ctx.llm =
531 Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
532 ctx.kv = fake_kv();
533 ctx.tools = echo_tool_invoker();
534
535 let thread = ThreadId::new("t-resume-approved");
536 let cp = checkpoint_with_pending_tool(thread.clone(), ctx.run_id);
537
538 let out = resume_from_checkpoint(
539 &ctx,
540 "sys",
541 cp,
542 ApprovalDecision::Approved,
543 RunOptions::default(),
544 )
545 .await
546 .unwrap();
547
548 assert_eq!(out, "done");
549
550 let history = ctx.short_term.load(thread, 8192).await.unwrap();
551 let tool_msgs: Vec<_> = history.iter().filter(|m| m.role == Role::Tool).collect();
552 assert_eq!(
553 tool_msgs.len(),
554 1,
555 "dispatched tool must leave a Role::Tool message"
556 );
557 assert_eq!(tool_msgs[0].tool_call_id.as_deref(), Some("tc-1"));
558 }
559
560 #[tokio::test]
561 async fn resume_approved_without_pending_calls_completes_and_injects_no_tool_message() {
562 let mut ctx = fake_context("resume-approved-none");
563 ctx.llm =
564 Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
565 ctx.kv = fake_kv();
566
567 let thread = ThreadId::new("t-resume-approved-none");
568 let cp = checkpoint_no_pending(thread.clone(), ctx.run_id);
569
570 let out = resume_from_checkpoint(
571 &ctx,
572 "sys",
573 cp,
574 ApprovalDecision::Approved,
575 RunOptions::default(),
576 )
577 .await
578 .unwrap();
579
580 assert_eq!(out, "done");
581
582 let history = ctx.short_term.load(thread, 8192).await.unwrap();
583 let tool_msgs = history.iter().filter(|m| m.role == Role::Tool).count();
584 assert_eq!(
585 tool_msgs, 0,
586 "approving a step with no pending tool calls must inject nothing"
587 );
588 }
589
590 #[tokio::test]
591 async fn resume_rejected_injects_tool_message_then_completes() {
592 let mut ctx = fake_context("resume-reject");
593 ctx.llm =
594 Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
595 ctx.kv = fake_kv();
596
597 let thread = ThreadId::new("t-resume-rejected");
598 let cp = checkpoint_no_pending(thread.clone(), ctx.run_id);
599
600 let out = resume_from_checkpoint(
601 &ctx,
602 "sys",
603 cp,
604 ApprovalDecision::Rejected {
605 reason: "no".into(),
606 },
607 RunOptions::default(),
608 )
609 .await
610 .unwrap();
611
612 assert_eq!(out, "done");
613
614 let history = ctx.short_term.load(thread, 8192).await.unwrap();
615 let rejection: Vec<_> = history
616 .iter()
617 .filter(|m| m.role == Role::Tool && m.content.contains("no"))
618 .collect();
619 assert_eq!(rejection.len(), 1, "rejected message must be appended");
620 assert!(rejection[0]
621 .content
622 .contains("Human reviewer rejected this step: no"));
623 }
624
625 #[tokio::test]
626 async fn resume_rejected_with_pending_calls_does_not_dispatch_them() {
627 let mut ctx = fake_context("resume-reject-pending");
628 ctx.llm =
629 Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
630 ctx.kv = fake_kv();
631 ctx.tools = echo_tool_invoker();
632
633 let thread = ThreadId::new("t-resume-reject-pending");
634 let cp = checkpoint_with_pending_tool(thread.clone(), ctx.run_id);
635
636 let out = resume_from_checkpoint(
637 &ctx,
638 "sys",
639 cp,
640 ApprovalDecision::Rejected {
641 reason: "blocked".into(),
642 },
643 RunOptions::default(),
644 )
645 .await
646 .unwrap();
647
648 assert_eq!(out, "done");
649
650 let history = ctx.short_term.load(thread, 8192).await.unwrap();
651 assert!(
652 !history
653 .iter()
654 .any(|m| m.tool_call_id.as_deref() == Some("tc-1")),
655 "a rejected step must NOT dispatch its pending tool calls"
656 );
657 assert!(
658 history
659 .iter()
660 .any(|m| m.role == Role::Tool && m.content.contains("blocked")),
661 "the rejection reason must be fed back to the model"
662 );
663 }
664
665 async fn seed_latched(ctx: &AgentContext, thread: &ThreadId) {
666 let mut latched = checkpoint_with_pending_tool(thread.clone(), ctx.run_id);
667 latched.resume_attempted = true;
668 ctx.kv
669 .put(
670 CHECKPOINT_BUCKET,
671 &ctx.run_id.to_string(),
672 serde_json::to_vec(&latched).unwrap().into(),
673 )
674 .await
675 .unwrap();
676 }
677
678 #[tokio::test]
679 async fn resume_retry_with_non_idempotent_pending_fails_closed() {
680 let mut ctx = fake_context("resume-retry-nonidem");
681 ctx.llm =
682 Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
683 ctx.kv = fake_kv();
684 ctx.tools = echo_tool_invoker(); let thread = ThreadId::new("t-retry-nonidem");
687 seed_latched(&ctx, &thread).await; let cp = checkpoint_with_pending_tool(thread.clone(), ctx.run_id);
689
690 let err = resume_from_checkpoint(
691 &ctx,
692 "sys",
693 cp,
694 ApprovalDecision::Approved,
695 RunOptions::default().with_checkpoint_bucket(CHECKPOINT_BUCKET),
696 )
697 .await
698 .unwrap_err();
699
700 match err {
701 Error::ResumeReplayBlocked { tools, .. } => assert_eq!(
702 tools,
703 vec!["echo".to_string()],
704 "the refused non-idempotent tool must be named"
705 ),
706 other => panic!("expected ResumeReplayBlocked, got {other:?}"),
707 }
708
709 let history = ctx.short_term.load(thread, 8192).await.unwrap();
710 assert!(
711 !history
712 .iter()
713 .any(|m| m.tool_call_id.as_deref() == Some("tc-1")),
714 "a fail-closed resume must NOT re-dispatch the non-idempotent pending call"
715 );
716 }
717
718 #[tokio::test]
719 async fn resume_retry_with_idempotent_pending_redispatches() {
720 let mut ctx = fake_context("resume-retry-idem");
721 ctx.llm =
722 Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
723 ctx.kv = fake_kv();
724 ctx.tools = Arc::new(FakeToolInvoker::new().with_idempotent_tool("echo", "echo back", Ok));
725
726 let thread = ThreadId::new("t-retry-idem");
727 seed_latched(&ctx, &thread).await;
728 let cp = checkpoint_with_pending_tool(thread.clone(), ctx.run_id);
729
730 let out = resume_from_checkpoint(
731 &ctx,
732 "sys",
733 cp,
734 ApprovalDecision::Approved,
735 RunOptions::default().with_checkpoint_bucket(CHECKPOINT_BUCKET),
736 )
737 .await
738 .unwrap();
739 assert_eq!(out, "done");
740
741 let history = ctx.short_term.load(thread, 8192).await.unwrap();
742 assert!(
743 history
744 .iter()
745 .any(|m| m.tool_call_id.as_deref() == Some("tc-1")),
746 "an idempotent tool is safe to re-dispatch even after a prior resume latch"
747 );
748 }
749
750 #[tokio::test]
751 async fn concurrent_resume_losing_the_cas_fails_closed() {
752 let mut ctx = fake_context("resume-cas-conflict");
756 ctx.llm =
757 Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
758 ctx.tools = echo_tool_invoker();
759 let thread = ThreadId::new("t-cas-conflict");
760 let unlatched =
761 serde_json::to_vec(&checkpoint_with_pending_tool(thread.clone(), ctx.run_id))
762 .unwrap()
763 .into();
764 ctx.kv = Arc::new(ConflictKv { value: unlatched });
765
766 let err = resume_from_checkpoint(
767 &ctx,
768 "sys",
769 checkpoint_with_pending_tool(thread, ctx.run_id),
770 ApprovalDecision::Approved,
771 RunOptions::default().with_checkpoint_bucket(CHECKPOINT_BUCKET),
772 )
773 .await
774 .unwrap_err();
775
776 assert!(
777 matches!(err, Error::ResumeReplayBlocked { .. }),
778 "losing the latch CAS to a concurrent resume must fail closed, got {err:?}"
779 );
780 }
781
782 #[tokio::test]
783 async fn first_resume_latches_attempt_before_dispatch() {
784 let mut ctx = fake_context("resume-latch");
785 ctx.llm =
786 Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
787 ctx.kv = fake_kv();
788 ctx.tools = echo_tool_invoker();
789
790 let thread = ThreadId::new("t-latch");
791 let run_id = ctx.run_id;
792 let cp = checkpoint_with_pending_tool(thread, run_id);
793
794 resume_from_checkpoint(
795 &ctx,
796 "sys",
797 cp,
798 ApprovalDecision::Approved,
799 RunOptions::default().with_checkpoint_bucket(CHECKPOINT_BUCKET),
800 )
801 .await
802 .unwrap();
803
804 let entry = ctx
805 .kv
806 .get(CHECKPOINT_BUCKET, &run_id.to_string())
807 .await
808 .unwrap()
809 .expect("first resume must persist the latched checkpoint");
810 let latched: RunCheckpoint = serde_json::from_slice(&entry.value).unwrap();
811 assert!(
812 latched.resume_attempted,
813 "first resume must latch resume_attempted=true before dispatch, so a retry is recognised"
814 );
815 }
816
817 #[test]
818 fn corrupt_checkpoint_bytes_treated_as_latched() {
819 assert!(
820 checkpoint_is_latched(b"not-json"),
821 "an unparseable stored checkpoint must read as latched — refuse rather than risk a re-fire"
822 );
823 let unlatched = serde_json::to_vec(&checkpoint_no_pending(
824 ThreadId::new("t-parse"),
825 RunId::new(),
826 ))
827 .unwrap();
828 assert!(
829 !checkpoint_is_latched(&unlatched),
830 "a well-formed un-latched checkpoint must read as not latched"
831 );
832 }
833
834 #[tokio::test]
835 async fn bucketless_resume_skips_latch_and_dispatches() {
836 let mut ctx = fake_context("resume-no-bucket");
837 ctx.llm =
838 Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
839 ctx.kv = fake_kv();
840 ctx.tools = echo_tool_invoker(); let thread = ThreadId::new("t-no-bucket");
843 let run_id = ctx.run_id;
844 let cp = checkpoint_with_pending_tool(thread.clone(), run_id);
845
846 let out = resume_from_checkpoint(
847 &ctx,
848 "sys",
849 cp,
850 ApprovalDecision::Approved,
851 RunOptions::default(),
852 )
853 .await
854 .unwrap();
855 assert_eq!(out, "done");
856
857 let history = ctx.short_term.load(thread, 8192).await.unwrap();
858 assert!(
859 history
860 .iter()
861 .any(|m| m.tool_call_id.as_deref() == Some("tc-1")),
862 "a within-process resume with no bucket is pre-dispatch-safe and dispatches directly"
863 );
864 assert!(
865 ctx.kv
866 .get(CHECKPOINT_BUCKET, &run_id.to_string())
867 .await
868 .unwrap()
869 .is_none(),
870 "no bucket means no latch is persisted"
871 );
872 }
873
874 #[tokio::test]
875 async fn concurrent_cas_conflict_with_idempotent_batch_proceeds() {
876 let mut ctx = fake_context("resume-conflict-idem");
877 ctx.llm =
878 Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
879 ctx.tools = Arc::new(FakeToolInvoker::new().with_idempotent_tool("echo", "echo back", Ok));
880 let thread = ThreadId::new("t-conflict-idem");
881 let unlatched =
882 serde_json::to_vec(&checkpoint_with_pending_tool(thread.clone(), ctx.run_id))
883 .unwrap()
884 .into();
885 ctx.kv = Arc::new(ConflictKv { value: unlatched });
886
887 let out = resume_from_checkpoint(
888 &ctx,
889 "sys",
890 checkpoint_with_pending_tool(thread.clone(), ctx.run_id),
891 ApprovalDecision::Approved,
892 RunOptions::default().with_checkpoint_bucket(CHECKPOINT_BUCKET),
893 )
894 .await
895 .unwrap();
896 assert_eq!(out, "done");
897
898 let history = ctx.short_term.load(thread, 8192).await.unwrap();
899 assert!(
900 history
901 .iter()
902 .any(|m| m.tool_call_id.as_deref() == Some("tc-1")),
903 "an idempotent tool proceeds even when it loses the latch CAS"
904 );
905 }
906
907 #[test]
908 fn legacy_checkpoint_without_resume_attempted_defaults_false() {
909 let run_id = RunId::new();
914 let legacy = format!(
915 r#"{{"run_id":"{run_id}","step_index":1,"thread_id":"t-legacy","messages":[],"pending_tool_calls":null,"created_at":"2026-06-12T00:00:00Z"}}"#
916 );
917 let back: RunCheckpoint = serde_json::from_str(&legacy).unwrap();
918 assert!(!back.resume_attempted);
919 }
920
921 async fn seed_checkpoint(
922 kv: &Arc<dyn crate::bus::KvStore>,
923 run_id: RunId,
924 created_at: chrono::DateTime<Utc>,
925 ) {
926 let key = run_id.to_string();
927 let mut cp = checkpoint_with_pending_tool(ThreadId::new("t-gc"), run_id);
928 cp.created_at = created_at;
929 let bytes = serde_json::to_vec(&cp).unwrap();
930 kv.put(CHECKPOINT_BUCKET, &key, bytes.into()).await.unwrap();
931 }
932
933 #[tokio::test]
934 async fn gc_checkpoints_reaps_only_entries_at_or_before_cutoff() {
935 let kv = fake_kv();
936 let now = Utc::now();
937 let cutoff = now - chrono::Duration::hours(1);
938 let stale = RunId::new();
939 let at_cutoff = RunId::new();
940 let fresh = RunId::new();
941 let stale_key = stale.to_string();
942 let at_cutoff_key = at_cutoff.to_string();
943 let fresh_key = fresh.to_string();
944 seed_checkpoint(&kv, stale, now - chrono::Duration::hours(2)).await;
945 seed_checkpoint(&kv, at_cutoff, cutoff).await;
946 seed_checkpoint(&kv, fresh, now).await;
947
948 let reaped = gc_checkpoints(kv.as_ref(), cutoff).await.unwrap();
949
950 assert_eq!(
951 reaped, 2,
952 "the stale checkpoint and the one exactly at the cutoff are both reaped (bound is <=)"
953 );
954 assert!(
955 kv.get(CHECKPOINT_BUCKET, &stale_key)
956 .await
957 .unwrap()
958 .is_none(),
959 "the stale checkpoint is deleted"
960 );
961 assert!(
962 kv.get(CHECKPOINT_BUCKET, &at_cutoff_key)
963 .await
964 .unwrap()
965 .is_none(),
966 "a checkpoint whose created_at equals the cutoff is reaped — the bound is inclusive"
967 );
968 assert!(
969 kv.get(CHECKPOINT_BUCKET, &fresh_key)
970 .await
971 .unwrap()
972 .is_some(),
973 "a checkpoint newer than the cutoff must survive the sweep"
974 );
975 }
976
977 #[tokio::test]
978 async fn gc_checkpoints_paged_reaps_eligible_across_pages() {
979 let kv = fake_kv();
980 let now = Utc::now();
981 let cutoff = now;
982 let stale = now - chrono::Duration::hours(1);
983 let ids: Vec<RunId> = (0..5).map(|_| RunId::new()).collect();
984 for id in &ids {
985 seed_checkpoint(&kv, *id, stale).await;
986 }
987
988 let reaped = gc_checkpoints_paged(kv.as_ref(), cutoff, 2).await.unwrap();
990
991 assert_eq!(reaped, 5);
992 for id in &ids {
993 assert!(
994 kv.get(CHECKPOINT_BUCKET, &id.to_string())
995 .await
996 .unwrap()
997 .is_none(),
998 "every eligible checkpoint across all pages is reaped"
999 );
1000 }
1001 }
1002
1003 #[tokio::test]
1004 async fn gc_checkpoints_skips_undeserializable_entry_without_failing() {
1005 let kv = fake_kv();
1006 let now = Utc::now();
1007 let stale = RunId::new();
1008 let stale_key = stale.to_string();
1009 seed_checkpoint(&kv, stale, now - chrono::Duration::hours(2)).await;
1010 kv.put(
1011 CHECKPOINT_BUCKET,
1012 "not-a-checkpoint",
1013 b"junk".to_vec().into(),
1014 )
1015 .await
1016 .unwrap();
1017
1018 let reaped = gc_checkpoints(kv.as_ref(), now).await.unwrap();
1019
1020 assert_eq!(reaped, 1, "the valid stale checkpoint is still reaped");
1021 assert!(
1022 kv.get(CHECKPOINT_BUCKET, &stale_key)
1023 .await
1024 .unwrap()
1025 .is_none(),
1026 "the stale checkpoint is gone"
1027 );
1028 assert!(
1029 kv.get(CHECKPOINT_BUCKET, "not-a-checkpoint")
1030 .await
1031 .unwrap()
1032 .is_some(),
1033 "an undeserializable entry is skipped, not deleted — a foreign value cannot stall the sweep"
1034 );
1035 }
1036
1037 #[tokio::test]
1038 async fn gc_checkpoints_empty_bucket_is_zero() {
1039 let kv = fake_kv();
1040 let reaped = gc_checkpoints(kv.as_ref(), Utc::now()).await.unwrap();
1041 assert_eq!(reaped, 0, "an empty bucket reaps nothing");
1042 }
1043
1044 #[tokio::test]
1045 async fn gc_checkpoints_propagates_enumerate_failure() {
1046 let kv = KeysFailKv;
1047 let err = gc_checkpoints(&kv, Utc::now()).await.unwrap_err();
1048 assert!(
1049 matches!(err, Error::Bus(BusError::Unsupported(_))),
1050 "a keys() failure must propagate — the sweep cannot enumerate, so it must surface the error, not silently report zero reaped"
1051 );
1052 }
1053
1054 #[tokio::test]
1055 async fn gc_checkpoints_skips_entry_whose_delete_fails() {
1056 let mut cp = checkpoint_with_pending_tool(ThreadId::new("t-del"), RunId::new());
1057 cp.created_at = Utc::now() - chrono::Duration::hours(1);
1058 let kv = DeleteFailKv {
1059 value: Bytes::from(serde_json::to_vec(&cp).unwrap()),
1060 };
1061
1062 let reaped = gc_checkpoints(&kv, Utc::now()).await.unwrap();
1063
1064 assert_eq!(
1065 reaped, 0,
1066 "a stale checkpoint whose delete fails is logged and skipped, not counted; the sweep still returns Ok"
1067 );
1068 }
1069
1070 #[tokio::test]
1071 async fn spawn_checkpoint_gc_reaps_stale_and_keeps_fresh() {
1072 let kv = fake_kv();
1073 let stale = RunId::new();
1074 let fresh = RunId::new();
1075 seed_checkpoint(&kv, stale, Utc::now() - chrono::Duration::hours(2)).await;
1076 seed_checkpoint(&kv, fresh, Utc::now()).await;
1077
1078 let handle = spawn_checkpoint_gc(
1080 Arc::clone(&kv),
1081 Duration::from_secs(3600),
1082 Duration::from_millis(5),
1083 );
1084 tokio::time::sleep(Duration::from_millis(80)).await;
1086
1087 assert!(
1088 kv.get(CHECKPOINT_BUCKET, &stale.to_string())
1089 .await
1090 .unwrap()
1091 .is_none(),
1092 "the spawned gc must reap a checkpoint older than the ttl"
1093 );
1094 assert!(
1095 kv.get(CHECKPOINT_BUCKET, &fresh.to_string())
1096 .await
1097 .unwrap()
1098 .is_some(),
1099 "a checkpoint within the ttl must survive"
1100 );
1101 drop(handle);
1102 }
1103
1104 #[tokio::test]
1105 async fn checkpoint_gc_handle_drop_stops_reaping() {
1106 let kv = fake_kv();
1107 let stale = RunId::new();
1108 seed_checkpoint(&kv, stale, Utc::now() - chrono::Duration::hours(2)).await;
1109
1110 let handle = spawn_checkpoint_gc(
1115 Arc::clone(&kv),
1116 Duration::from_secs(3600),
1117 Duration::from_millis(5),
1118 );
1119 drop(handle);
1120 tokio::time::sleep(Duration::from_millis(80)).await;
1121
1122 assert!(
1123 kv.get(CHECKPOINT_BUCKET, &stale.to_string())
1124 .await
1125 .unwrap()
1126 .is_some(),
1127 "dropping the handle aborts the task, so the stale checkpoint is never reaped"
1128 );
1129 }
1130
1131 #[tokio::test]
1132 async fn spawn_checkpoint_gc_with_out_of_range_ttl_skips_sweep() {
1133 let kv = fake_kv();
1134 let stale = RunId::new();
1135 seed_checkpoint(&kv, stale, Utc::now() - chrono::Duration::hours(2)).await;
1136
1137 let handle = spawn_checkpoint_gc(Arc::clone(&kv), Duration::MAX, Duration::from_millis(5));
1140 tokio::time::sleep(Duration::from_millis(80)).await;
1141
1142 assert!(
1143 kv.get(CHECKPOINT_BUCKET, &stale.to_string())
1144 .await
1145 .unwrap()
1146 .is_some(),
1147 "an out-of-range ttl must skip the sweep, never reap against a degenerate cutoff"
1148 );
1149 drop(handle);
1150 }
1151}