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}