Current section
Files
Jump to
Current section
Files
lib/cmdc/checkpoint.ex
defmodule CMDC.Checkpoint do
@moduledoc """
会话快照(checkpoint)一等公民 API。
让 Agent 在 BEAM 重启、Gateway 滚动升级、用户跨设备恢复等场景下
能保留完整对话上下文继续推进。
## 设计
- **抽象层**:`CMDC.Checkpoint.Backend` behaviour 定义 4 个回调
(`save/3` / `load/2` / `list/2` / `delete/2`),实现可插拔。
- **默认后端**:`CMDC.Checkpoint.Backend.ETS`(进程内)/
`CMDC.Checkpoint.Backend.DETS`(轻持久化,单节点)。
- **可选后端**:`cmdc_memory_pg` 子库提供
`CMDC.Checkpoint.Backend.Postgres`。
- **统一 payload**:`CMDC.Checkpoint.Snapshot` struct 带
`@schema_version`,序列化时丢弃 pid / Stream / Task 等不可重建字段。
## Quick Start
iex> {:ok, snap} = CMDC.Checkpoint.save(session_id, agent_state)
iex> {:ok, ^snap} = CMDC.Checkpoint.load(session_id)
## 与 ACP / Steering 的关系
- `cmdc_acp` 的会话恢复(Zed 切换 thread)= `CMDC.Checkpoint.load/2`
- 10A Steering 的 `:max_steering_queue` 队列在 snapshot 中序列化
- `Plugin.Builtin.HumanApproval` 在 `:session_start` 重建 pending approval
队列(不可序列化字段策略)
"""
alias CMDC.Checkpoint.Snapshot
# ==========================================================================
# Public API
# ==========================================================================
@typedoc "会话标识符(通常是 `Agent.State.session_id`)。"
@type session_id :: String.t()
@typedoc "Backend 实现模块或 `{module, opts}` 元组。"
@type backend :: module() | {module(), keyword()}
@typedoc "存储/读取/列表/删除统一选项。"
@type opts :: keyword()
@doc """
保存 Agent 状态快照。
## 参数
- `session_id` — 会话 ID
- `state` — Agent 内部状态 struct 或任意可序列化 map
- `opts`:
- `:backend` — 覆盖默认 backend(`Application.get_env(:cmdc, :checkpoint_backend, ETS)`)
- `:label` — 可选标签(如 `"after_plan_approved"`),方便回溯
- `:metadata` — 用户附加 map
返回 `{:ok, Snapshot.t()}` 含生成的 `:checkpoint_id` 与 `:saved_at`。
## Examples
iex> {:ok, snap} = CMDC.Checkpoint.save("sess-1", state, label: "before_compact")
iex> snap.session_id
"sess-1"
"""
@spec save(session_id(), Snapshot.serializable(), opts()) ::
{:ok, Snapshot.t()} | {:error, term()}
def save(session_id, state, opts \\ []) when is_binary(session_id) do
{backend_module, backend_opts} = resolve_backend(opts)
started_at = System.monotonic_time(:millisecond)
snapshot =
Snapshot.new(
session_id: session_id,
state: state,
label: Keyword.get(opts, :label),
metadata: Keyword.get(opts, :metadata, %{})
)
result =
case backend_module.save(session_id, snapshot, backend_opts) do
:ok -> {:ok, snapshot}
{:ok, _} = ok -> ok
{:error, _} = err -> err
end
emit_save_telemetry(session_id, backend_module, snapshot, result, started_at, opts)
result
end
defp emit_save_telemetry(session_id, backend_module, snapshot, result, started_at, opts) do
duration_ms = System.monotonic_time(:millisecond) - started_at
outcome = if match?({:ok, _}, result), do: :ok, else: :error
snapshot_bytes =
try do
snapshot |> :erlang.term_to_binary() |> byte_size()
rescue
_ -> 0
end
CMDC.Telemetry.execute(
[:cmdc, :checkpoint, :save, :stop],
%{duration_ms: duration_ms, snapshot_bytes: snapshot_bytes},
%{
session_id: session_id,
backend: backend_module,
label: Keyword.get(opts, :label),
outcome: outcome
}
)
end
@doc """
加载最新一份会话快照。
## 选项
- `:backend` — 覆盖默认 backend
- `:checkpoint_id` — 加载指定 checkpoint(默认加载最新)
"""
@spec load(session_id(), opts()) :: {:ok, Snapshot.t()} | {:error, :not_found | term()}
def load(session_id, opts \\ []) when is_binary(session_id) do
{backend_module, backend_opts} = resolve_backend(opts)
started_at = System.monotonic_time(:millisecond)
extra =
case Keyword.fetch(opts, :checkpoint_id) do
{:ok, id} -> Keyword.put(backend_opts, :checkpoint_id, id)
:error -> backend_opts
end
result = backend_module.load(session_id, extra)
emit_load_telemetry(session_id, backend_module, result, started_at)
result
end
defp emit_load_telemetry(session_id, backend_module, result, started_at) do
duration_ms = System.monotonic_time(:millisecond) - started_at
{snapshot_bytes, hit?, outcome} =
case result do
{:ok, snap} ->
bytes =
try do
snap |> :erlang.term_to_binary() |> byte_size()
rescue
_ -> 0
end
{bytes, true, :ok}
{:error, :not_found} ->
{0, false, :not_found}
{:error, _} ->
{0, false, :error}
end
CMDC.Telemetry.execute(
[:cmdc, :checkpoint, :load, :stop],
%{duration_ms: duration_ms, snapshot_bytes: snapshot_bytes, hit?: hit?},
%{session_id: session_id, backend: backend_module, outcome: outcome}
)
end
@doc """
列出指定会话所有快照(按 `saved_at` 倒序)。
"""
@spec list(session_id(), opts()) :: {:ok, [Snapshot.t()]} | {:error, term()}
def list(session_id, opts \\ []) when is_binary(session_id) do
{backend_module, backend_opts} = resolve_backend(opts)
backend_module.list(session_id, backend_opts)
end
@doc """
删除指定会话的快照。
## 选项
- `:checkpoint_id` — 仅删除指定 checkpoint(默认删除该 session 全部快照)
- `:backend` — 覆盖默认 backend
"""
@spec delete(session_id(), opts()) :: :ok | {:error, term()}
def delete(session_id, opts \\ []) when is_binary(session_id) do
{backend_module, backend_opts} = resolve_backend(opts)
case Keyword.fetch(opts, :checkpoint_id) do
{:ok, id} ->
backend_module.delete(session_id, Keyword.put(backend_opts, :checkpoint_id, id))
:error ->
backend_module.delete(session_id, backend_opts)
end
end
# ==========================================================================
# 私有:backend 解析
# ==========================================================================
@default_backend CMDC.Checkpoint.Backend.ETS
@spec resolve_backend(opts()) :: {module(), keyword()}
defp resolve_backend(opts) do
case Keyword.get(opts, :backend) do
nil ->
{Application.get_env(:cmdc, :checkpoint_backend, @default_backend), []}
{module, backend_opts} when is_atom(module) and is_list(backend_opts) ->
{module, backend_opts}
module when is_atom(module) ->
{module, []}
end
end
# ==========================================================================
# behaviour 一致性校验(开发期保护)
# ==========================================================================
@doc false
def __after_compile__(_env, _bytecode) do
:ok
end
@doc false
@spec default_backend() :: module()
def default_backend, do: @default_backend
end