Skip to main content

klieo_core/
checkpoint.rs

1//! Durable continuation state for a suspended run (ADR-045).
2//!
3//! Pure data depending only on `ids` + `llm`, so both the `error` layer
4//! (`Error::Suspended` carries a `RunCheckpoint`) and the `runtime` layer (which
5//! builds and replays it) can name these types without a module cycle. The
6//! persist + resume behaviour lives in [`crate::runtime::resume_from_checkpoint`].
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11use crate::ids::{RunId, ThreadId};
12use crate::llm::{Message, ToolCall};
13
14/// Bucket under which suspended-run checkpoints persist in `KvStore`.
15pub const CHECKPOINT_BUCKET: &str = "klieo.run-checkpoints";
16
17/// Minimal state required to resume a suspended run. Excludes the `Agent`
18/// (its `Arc<dyn _>` ports are not serializable) — only the conversation and
19/// the tool calls awaiting approval are captured.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[non_exhaustive]
22pub struct RunCheckpoint {
23    /// Stable across resume; doubles as the `KvStore` key for this checkpoint.
24    pub run_id: RunId,
25    /// 1-indexed step the resume re-enters the loop at.
26    pub step_index: u32,
27    /// Thread whose short-term history is cleared and restored from `messages`.
28    pub thread_id: ThreadId,
29    /// Short-term history captured at suspend time, oldest first.
30    pub messages: Vec<Message>,
31    /// Tool calls held back for approval; `Some` only when the paused step's
32    /// finish reason was `ToolCalls`.
33    pub pending_tool_calls: Option<Vec<ToolCall>>,
34    /// Latched `true` by the first resume before it dispatches the pending
35    /// calls, so a later (retried, cross-process) resume can tell it is not the
36    /// first attempt. A retry that finds this set refuses to re-dispatch a
37    /// non-idempotent pending call rather than risk a duplicate side effect
38    /// (ADR-045 fail-closed). `#[serde(default)]` keeps pre-existing persisted
39    /// checkpoints (no field) readable — they default to `false`, the
40    /// first-attempt state.
41    #[serde(default)]
42    pub resume_attempted: bool,
43    /// Suspend timestamp, for TTL/GC of abandoned checkpoints.
44    pub created_at: DateTime<Utc>,
45}
46
47impl RunCheckpoint {
48    /// Test-only fixture.
49    #[cfg(test)]
50    pub(crate) fn for_test() -> Self {
51        Self {
52            run_id: RunId::new(),
53            step_index: 1,
54            thread_id: ThreadId::new("t-test"),
55            messages: vec![],
56            pending_tool_calls: None,
57            resume_attempted: false,
58            created_at: Utc::now(),
59        }
60    }
61}
62
63/// Operator decision delivered to `resume_from_checkpoint`.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[non_exhaustive]
66pub enum ApprovalDecision {
67    /// The human reviewer approved the paused step.
68    Approved,
69    /// The human reviewer refused the paused step.
70    Rejected {
71        /// Reason surfaced to the model in the injected tool message.
72        reason: String,
73    },
74}