Files
Genarrative/rust/crates/agent-runtime/src/durable.rs
T
kdletters 202279c6d9 新增独立 Agent Runtime Rust 工作区
新增 Core、Engine、Runtime、SQLite、Provider、MCP、Skill、Codex、CLI 与 DAG crate

补齐 OpenAI endpoint 配置、Provider 实例/协议路由和统一工具权限边界

加入持久化、lease、checkpoint、reconciliation、审批恢复与消息历史回归

加入独立 workspace CI、依赖边界、能力集和 Fake Agent 测试脚本

同步建设计划、TODO、架构、测试与验收文档
2026-09-06 17:44:54 +08:00

1267 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Portable Durable Runtime facade.
//!
//! The neutral command/view types and `DurableStore` trait live in
//! `agent-runtime-contracts`; the SQLite adapter is implemented by the
//! sibling `agent-runtime-sqlite` crate so this facade stays portable.
pub use agent_runtime_contracts::*;
use agent_runtime_core::{RuntimeEvent, RuntimeSnapshot};
use serde_json::Value;
use std::time::Duration;
/// 面向任意 [`DurableStore`] 的轻量运行控制 facade。
///
/// 该类型只负责拥有和转发中立 command/query,不重新实现事务,也不引入
/// SQLite、线程或 CLI 生命周期。它让后续内存、远端或其它持久化 adapter
/// 可以直接装配同一组 Runtime 控制面 APISQLite-backed run control is
/// provided by `agent-runtime-sqlite`.
#[derive(Clone, Debug)]
pub struct DurableRuntime<S> {
store: S,
}
impl<S> DurableRuntime<S> {
/// 用调用方提供的 durable adapter 创建 facade。
pub fn new(store: S) -> Self {
Self { store }
}
/// 只读访问 adapter,便于调用方观察其自有的实现状态。
pub fn store(&self) -> &S {
&self.store
}
/// 取回 adapter 的可变引用;所有 durable 原子性仍由 adapter 保证。
pub fn store_mut(&mut self) -> &mut S {
&mut self.store
}
/// 结束 facade 生命周期并取回 adapter 所有权。
pub fn into_store(self) -> S {
self.store
}
}
impl<S> DurableRuntime<S>
where
S: DurableStore,
{
pub fn create_run_bundle(
&self,
bundle: DurableRunBundle,
) -> Result<DurableBundleResult, S::Error> {
self.store.create_run_bundle(bundle)
}
pub fn get_run(&self, run_id: &str) -> Result<Option<DurableRunView>, S::Error> {
self.store.get_run(run_id)
}
pub fn get_session(&self, session_id: &str) -> Result<Option<DurableSessionView>, S::Error> {
self.store.get_session(session_id)
}
pub fn is_cancel_requested(&self, run_id: &str) -> Result<bool, S::Error> {
self.store.is_cancel_requested(run_id)
}
pub fn runtime_id_for_run(&self, run_id: &str) -> Result<Option<String>, S::Error> {
self.store.runtime_id_for_run(run_id)
}
pub fn update_session(
&self,
session_id: &str,
status: &str,
metadata: Option<Value>,
) -> Result<DurableSessionView, S::Error> {
self.store.update_session(session_id, status, metadata)
}
pub fn claim_run_with_lease(
&self,
run_id: &str,
worker_id: &str,
lease_token: &str,
lease_duration: Duration,
) -> Result<DurableClaimResult, S::Error> {
self.store
.claim_run_with_lease(run_id, worker_id, lease_token, lease_duration)
}
pub fn get_run_lease(&self, run_id: &str) -> Result<Option<DurableLeaseView>, S::Error> {
self.store.get_run_lease(run_id)
}
pub fn heartbeat_run(
&self,
run_id: &str,
worker_id: &str,
lease_token: &str,
lease_duration: Duration,
) -> Result<DurableLeaseView, S::Error> {
self.store
.heartbeat_run(run_id, worker_id, lease_token, lease_duration)
}
pub fn release_run_lease(
&self,
run_id: &str,
worker_id: &str,
lease_token: &str,
) -> Result<DurableRunView, S::Error> {
self.store.release_run_lease(run_id, worker_id, lease_token)
}
pub fn request_cancel(&self, run_id: &str) -> Result<DurableRunView, S::Error> {
self.store.request_cancel(run_id)
}
pub fn list_stale_run_ids(&self, limit: usize, now_ms: i64) -> Result<Vec<String>, S::Error> {
self.store.list_stale_run_ids(limit, now_ms)
}
pub fn requeue_safe_run(&self, run_id: &str) -> Result<DurableRunView, S::Error> {
self.store.requeue_safe_run(run_id)
}
pub fn read_checkpoint(&self, run_id: &str) -> Result<Option<DurableCheckpointView>, S::Error> {
self.store.read_checkpoint(run_id)
}
pub fn read_checkpoint_with_lease(
&self,
run_id: &str,
worker_id: &str,
lease_token: &str,
) -> Result<Option<DurableCheckpointView>, S::Error> {
self.store
.read_checkpoint_with_lease(run_id, worker_id, lease_token)
}
pub fn save_checkpoint_with_lease(
&self,
checkpoint: DurableCheckpointInput,
worker_id: &str,
lease_token: &str,
) -> Result<DurableCheckpointView, S::Error> {
self.store
.save_checkpoint_with_lease(checkpoint, worker_id, lease_token)
}
pub fn save_checkpoint_with_runtime_and_lease(
&self,
commit: DurableCheckpointRuntimeCommit,
worker_id: &str,
lease_token: &str,
) -> Result<DurableCheckpointView, S::Error> {
self.store
.save_checkpoint_with_runtime_and_lease(commit, worker_id, lease_token)
}
pub fn record_reconciliation_result(
&self,
run_id: &str,
phase: &str,
external_id: &str,
step: i64,
attempt: i64,
messages: Value,
) -> Result<DurableCheckpointView, S::Error> {
self.store
.record_reconciliation_result(run_id, phase, external_id, step, attempt, messages)
}
pub fn create_approval(
&self,
approval: DurableApprovalInput,
) -> Result<DurableApprovalView, S::Error> {
self.store.create_approval(approval)
}
pub fn get_approval(&self, approval_id: &str) -> Result<Option<DurableApprovalView>, S::Error> {
self.store.get_approval(approval_id)
}
pub fn list_approvals_for_run(
&self,
run_id: &str,
) -> Result<Vec<DurableApprovalView>, S::Error> {
self.store.list_approvals_for_run(run_id)
}
pub fn get_approval_for_run_call(
&self,
run_id: &str,
tool_call_id: &str,
) -> Result<Option<DurableApprovalView>, S::Error> {
self.store.get_approval_for_run_call(run_id, tool_call_id)
}
pub fn resolve_approval(
&self,
resolution: DurableApprovalResolution,
) -> Result<DurableApprovalView, S::Error> {
self.store.resolve_approval(resolution)
}
pub fn cancel_pending_approvals(&self, run_id: &str) -> Result<usize, S::Error> {
self.store.cancel_pending_approvals(run_id)
}
pub fn create_tool_call(
&self,
call: DurableToolCallInput,
) -> Result<DurableToolCallView, S::Error> {
self.store.create_tool_call(call)
}
pub fn complete_tool_call(
&self,
call_id: &str,
status: &str,
result: Value,
) -> Result<DurableToolCallView, S::Error> {
self.store.complete_tool_call(call_id, status, result)
}
/// Atomically persist a tool-call row with the corresponding Core runtime
/// snapshot/events when the adapter supports the extended contract.
pub fn create_tool_call_with_runtime_and_lease(
&self,
commit: DurableToolCallRuntimeCommit,
) -> Result<DurableToolCallView, S::Error>
where
S::Error: From<DurableStoreUnsupported>,
{
self.store.create_tool_call_with_runtime_and_lease(commit)
}
/// Atomically finish a tool-call row and commit its Core runtime events.
/// Checkpoints remain on their existing command because they have a
/// separate fencing contract.
pub fn complete_tool_call_with_runtime_and_lease(
&self,
commit: DurableToolCallRuntimeCommit,
status: &str,
result: Value,
) -> Result<DurableToolCallView, S::Error>
where
S::Error: From<DurableStoreUnsupported>,
{
self.store
.complete_tool_call_with_runtime_and_lease(commit, status, result)
}
/// Atomically persist a requested tool row, its checkpoint and Core runtime
/// event batch. Adapter 负责在一个事务中执行 lease/CAS 校验。
pub fn create_tool_call_with_checkpoint_runtime_and_lease(
&self,
commit: DurableToolCallCheckpointRuntimeCommit,
) -> Result<DurableToolCallView, S::Error>
where
S::Error: From<DurableStoreUnsupported>,
{
self.store
.create_tool_call_with_checkpoint_runtime_and_lease(commit)
}
/// Atomically persist a completed tool row, its checkpoint and Core runtime
/// event batch; any failed validation must roll the whole adapter transaction back.
pub fn complete_tool_call_with_checkpoint_runtime_and_lease(
&self,
commit: DurableToolCallCheckpointRuntimeCommit,
status: &str,
result: Value,
) -> Result<DurableToolCallView, S::Error>
where
S::Error: From<DurableStoreUnsupported>,
{
self.store
.complete_tool_call_with_checkpoint_runtime_and_lease(commit, status, result)
}
pub fn get_tool_call(&self, call_id: &str) -> Result<Option<DurableToolCallView>, S::Error> {
self.store.get_tool_call(call_id)
}
pub fn list_tool_calls_for_run(
&self,
run_id: &str,
) -> Result<Vec<DurableToolCallView>, S::Error> {
self.store.list_tool_calls_for_run(run_id)
}
pub fn queue_approved_run(&self, approval_id: &str) -> Result<DurableRunView, S::Error> {
self.store.queue_approved_run(approval_id)
}
pub fn finish_run_with_runtime(
&self,
command: DurableFinishCommand,
) -> Result<DurableRunView, S::Error> {
self.store.finish_run_with_runtime(command)
}
pub fn mark_cancelled_with_lease(
&self,
run_id: &str,
worker_id: &str,
lease_token: &str,
output: Option<Value>,
) -> Result<DurableRunView, S::Error> {
self.store
.mark_cancelled_with_lease(run_id, worker_id, lease_token, output)
}
pub fn mark_cancelled(
&self,
run_id: &str,
output: Option<Value>,
) -> Result<DurableRunView, S::Error> {
self.store.mark_cancelled(run_id, output)
}
pub fn recover_expired_run(&self, run_id: &str) -> Result<DurableRunView, S::Error> {
self.store.recover_expired_run(run_id)
}
pub fn recover_expired_run_with_runtime(
&self,
commit: DurableRecoveryCommit,
) -> Result<DurableRunView, S::Error> {
self.store.recover_expired_run_with_runtime(commit)
}
pub fn upsert_external_session(
&self,
session: DurableExternalSessionInput,
) -> Result<DurableExternalSessionView, S::Error> {
self.store.upsert_external_session(session)
}
pub fn update_external_session(
&self,
id: &str,
external_id: &str,
status: &str,
metadata: Value,
) -> Result<DurableExternalSessionView, S::Error> {
self.store
.update_external_session(id, external_id, status, metadata)
}
pub fn get_external_session(
&self,
id: &str,
) -> Result<Option<DurableExternalSessionView>, S::Error> {
self.store.get_external_session(id)
}
pub fn list_external_sessions(
&self,
statuses: &[&str],
run_id: Option<&str>,
limit: usize,
) -> Result<Vec<DurableExternalSessionView>, S::Error> {
self.store.list_external_sessions(statuses, run_id, limit)
}
pub fn load_runtime_snapshot(
&self,
runtime_id: &str,
) -> Result<Option<RuntimeSnapshot>, S::Error> {
self.store.load_runtime_snapshot(runtime_id)
}
pub fn commit_runtime_snapshot(
&self,
runtime_id: &str,
expected_revision: Option<u64>,
snapshot: &RuntimeSnapshot,
events: &[RuntimeEvent],
) -> Result<(), S::Error> {
self.store
.commit_runtime_snapshot(runtime_id, expected_revision, snapshot, events)
}
}
/// A deliberately small, test-only adapter for the generic durable facade.
///
/// This is not a second production store: the contract has many methods and a
/// fake must still implement all of them, but the test only needs the run,
/// lease, snapshot-CAS and terminal paths. Unsupported methods return an
/// explicit error instead of silently pretending to persist data.
#[cfg(test)]
mod contract_tests {
use super::*;
use agent_runtime_core::{RunSnapshot, RuntimeEventKind, RuntimeSnapshot, reduce};
use serde_json::json;
use std::collections::BTreeMap;
use std::sync::{Mutex, MutexGuard};
use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;
#[derive(Debug, Error)]
enum MemoryError {
#[error("in-memory durable store lock poisoned")]
LockPoisoned,
#[error("in-memory durable store conflict: {0}")]
Conflict(String),
#[error("in-memory durable store invalid input: {0}")]
Invalid(String),
#[error("in-memory durable store entity not found: {0}")]
NotFound(String),
#[error("in-memory durable store operation is intentionally unsupported: {0}")]
Unsupported(&'static str),
}
#[derive(Default)]
struct MemoryState {
sessions: BTreeMap<String, DurableSessionView>,
runs: BTreeMap<String, DurableRunView>,
runtime_ids: BTreeMap<String, String>,
leases: BTreeMap<String, DurableLeaseView>,
attempts: BTreeMap<String, i64>,
snapshots: BTreeMap<String, RuntimeSnapshot>,
}
struct InMemoryDurableStore {
state: Mutex<MemoryState>,
}
impl Default for InMemoryDurableStore {
fn default() -> Self {
Self {
state: Mutex::new(MemoryState::default()),
}
}
}
impl InMemoryDurableStore {
fn lock(&self) -> Result<MutexGuard<'_, MemoryState>, MemoryError> {
self.state.lock().map_err(|_| MemoryError::LockPoisoned)
}
fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| {
duration.as_millis().min(i64::MAX as u128) as i64
})
}
fn unsupported<T>(operation: &'static str) -> Result<T, MemoryError> {
Err(MemoryError::Unsupported(operation))
}
fn commit_snapshot(
state: &mut MemoryState,
runtime_id: &str,
expected_revision: Option<u64>,
snapshot: &RuntimeSnapshot,
events: &[RuntimeEvent],
) -> Result<(), MemoryError> {
let actual = state
.snapshots
.get(runtime_id)
.map(RuntimeSnapshot::revision);
if actual != expected_revision {
return Err(MemoryError::Conflict(format!(
"runtime revision expected={expected_revision:?} actual={actual:?}"
)));
}
if snapshot.runtime_id() != runtime_id || events.is_empty() {
return Err(MemoryError::Invalid(
"snapshot/event identity 无效".to_owned(),
));
}
snapshot
.validate()
.map_err(|error| MemoryError::Invalid(error.to_string()))?;
let previous = expected_revision.unwrap_or(0);
if snapshot.revision() <= previous
|| snapshot.revision() - previous != events.len() as u64
{
return Err(MemoryError::Invalid(
"snapshot revision 与 event 数量不一致".to_owned(),
));
}
for (offset, event) in events.iter().enumerate() {
let expected = previous + offset as u64 + 1;
if event.runtime_id() != runtime_id
|| event.revision() != expected
|| event.schema_version() != agent_runtime_core::RUNTIME_EVENT_SCHEMA_VERSION
{
return Err(MemoryError::Invalid(
"runtime event identity/revision 无效".to_owned(),
));
}
}
state
.snapshots
.insert(runtime_id.to_owned(), snapshot.clone());
Ok(())
}
fn terminal_status(target: DurableFinishTarget) -> &'static str {
match target {
DurableFinishTarget::Completed => "completed",
DurableFinishTarget::Failed => "failed",
DurableFinishTarget::Cancelled => "cancelled",
}
}
}
impl DurableStore for InMemoryDurableStore {
type Error = MemoryError;
fn create_run_bundle(
&self,
bundle: DurableRunBundle,
) -> Result<DurableBundleResult, Self::Error> {
let mut state = self.lock()?;
if bundle.run.session_id != bundle.session.id {
return Err(MemoryError::Invalid(
"run/session identity 不一致".to_owned(),
));
}
if bundle.snapshot.runtime_id() != bundle.runtime_id {
return Err(MemoryError::Invalid("runtime identity 不一致".to_owned()));
}
bundle
.snapshot
.validate()
.map_err(|error| MemoryError::Invalid(error.to_string()))?;
if state.sessions.contains_key(&bundle.session.id)
|| state.runs.contains_key(&bundle.run.id)
|| state.snapshots.contains_key(&bundle.runtime_id)
{
return Err(MemoryError::Conflict("bundle identity 已存在".to_owned()));
}
let now = Self::now_ms();
let session_id = bundle.session.id.clone();
let run_id = bundle.run.id.clone();
let runtime_id = bundle.runtime_id.clone();
state.sessions.insert(
session_id.clone(),
DurableSessionView {
id: session_id.clone(),
agent_id: bundle.session.agent_id,
status: bundle.session.status,
metadata: bundle.session.metadata,
created_at: now,
updated_at: now,
},
);
state.runs.insert(
run_id.clone(),
DurableRunView {
id: run_id.clone(),
session_id,
status: bundle.run.status,
revision: 0,
input: bundle.run.input,
output: None,
cancel_requested: false,
created_at: now,
updated_at: now,
},
);
state.runtime_ids.insert(run_id.clone(), runtime_id.clone());
state.snapshots.insert(runtime_id.clone(), bundle.snapshot);
Ok(DurableBundleResult {
session_id: state
.runs
.get(&run_id)
.expect("run was inserted")
.session_id
.clone(),
run_id,
runtime_id,
})
}
fn get_run(&self, run_id: &str) -> Result<Option<DurableRunView>, Self::Error> {
Ok(self.lock()?.runs.get(run_id).cloned())
}
fn get_session(&self, session_id: &str) -> Result<Option<DurableSessionView>, Self::Error> {
Ok(self.lock()?.sessions.get(session_id).cloned())
}
fn is_cancel_requested(&self, run_id: &str) -> Result<bool, Self::Error> {
self.lock()?
.runs
.get(run_id)
.map(|run| run.cancel_requested)
.ok_or_else(|| MemoryError::NotFound(format!("run {run_id}")))
}
fn runtime_id_for_run(&self, run_id: &str) -> Result<Option<String>, Self::Error> {
Ok(self.lock()?.runtime_ids.get(run_id).cloned())
}
fn update_session(
&self,
session_id: &str,
status: &str,
metadata: Option<Value>,
) -> Result<DurableSessionView, Self::Error> {
let mut state = self.lock()?;
let session = state
.sessions
.get_mut(session_id)
.ok_or_else(|| MemoryError::NotFound(format!("session {session_id}")))?;
session.status = status.to_owned();
if let Some(metadata) = metadata {
session.metadata = metadata;
}
session.updated_at = Self::now_ms();
Ok(session.clone())
}
fn claim_run_with_lease(
&self,
run_id: &str,
worker_id: &str,
lease_token: &str,
lease_duration: Duration,
) -> Result<DurableClaimResult, Self::Error> {
let mut state = self.lock()?;
if state.leases.contains_key(run_id) {
return Err(MemoryError::Conflict(format!("run {run_id} 已有 lease")));
}
let attempt = {
let attempt = state.attempts.entry(run_id.to_owned()).or_insert(0);
*attempt += 1;
*attempt
};
let now = Self::now_ms();
let claimed_run = {
let run = state
.runs
.get_mut(run_id)
.ok_or_else(|| MemoryError::NotFound(format!("run {run_id}")))?;
if run.status != "queued" {
return Err(MemoryError::Conflict(format!(
"run {run_id} 当前状态 {} 不能 claim",
run.status
)));
}
run.status = "running".to_owned();
run.updated_at = now;
run.clone()
};
let duration_ms = lease_duration.as_millis().min(i64::MAX as u128) as i64;
let lease = DurableLeaseView {
run_id: run_id.to_owned(),
worker_id: worker_id.to_owned(),
lease_token: lease_token.to_owned(),
lease_expires_at: now.saturating_add(duration_ms),
heartbeat_at: now,
attempt,
};
state.leases.insert(run_id.to_owned(), lease.clone());
Ok(DurableClaimResult {
run: claimed_run,
lease,
})
}
fn get_run_lease(&self, run_id: &str) -> Result<Option<DurableLeaseView>, Self::Error> {
Ok(self.lock()?.leases.get(run_id).cloned())
}
fn heartbeat_run(
&self,
run_id: &str,
worker_id: &str,
lease_token: &str,
lease_duration: Duration,
) -> Result<DurableLeaseView, Self::Error> {
let mut state = self.lock()?;
let lease = state
.leases
.get_mut(run_id)
.ok_or_else(|| MemoryError::NotFound(format!("lease {run_id}")))?;
if lease.worker_id != worker_id || lease.lease_token != lease_token {
return Err(MemoryError::Conflict(
"lease fencing identity 不匹配".to_owned(),
));
}
let now = Self::now_ms();
lease.heartbeat_at = now;
lease.lease_expires_at =
now.saturating_add(lease_duration.as_millis().min(i64::MAX as u128) as i64);
Ok(lease.clone())
}
fn release_run_lease(
&self,
run_id: &str,
worker_id: &str,
lease_token: &str,
) -> Result<DurableRunView, Self::Error> {
let mut state = self.lock()?;
let lease = state
.leases
.get(run_id)
.ok_or_else(|| MemoryError::NotFound(format!("lease {run_id}")))?;
if lease.worker_id != worker_id || lease.lease_token != lease_token {
return Err(MemoryError::Conflict(
"lease fencing identity 不匹配".to_owned(),
));
}
state.leases.remove(run_id);
let run = state
.runs
.get_mut(run_id)
.ok_or_else(|| MemoryError::NotFound(format!("run {run_id}")))?;
run.status = "reconciling".to_owned();
run.updated_at = Self::now_ms();
Ok(run.clone())
}
fn request_cancel(&self, run_id: &str) -> Result<DurableRunView, Self::Error> {
let mut state = self.lock()?;
let run = state
.runs
.get_mut(run_id)
.ok_or_else(|| MemoryError::NotFound(format!("run {run_id}")))?;
run.cancel_requested = true;
if matches!(run.status.as_str(), "queued" | "running") {
run.status = "cancel_requested".to_owned();
}
run.updated_at = Self::now_ms();
Ok(run.clone())
}
fn list_stale_run_ids(
&self,
limit: usize,
now_ms: i64,
) -> Result<Vec<String>, Self::Error> {
Ok(self
.lock()?
.leases
.iter()
.filter(|(_, lease)| lease.lease_expires_at <= now_ms)
.map(|(run_id, _)| run_id.clone())
.take(limit)
.collect())
}
fn requeue_safe_run(&self, run_id: &str) -> Result<DurableRunView, Self::Error> {
let mut state = self.lock()?;
let run = state
.runs
.get_mut(run_id)
.ok_or_else(|| MemoryError::NotFound(format!("run {run_id}")))?;
if run.status != "reconciling" {
return Err(MemoryError::Conflict(format!(
"run {run_id} 不是 reconciling"
)));
}
run.status = "queued".to_owned();
run.updated_at = Self::now_ms();
Ok(run.clone())
}
fn read_checkpoint(
&self,
_run_id: &str,
) -> Result<Option<DurableCheckpointView>, Self::Error> {
Self::unsupported("read_checkpoint")
}
fn read_checkpoint_with_lease(
&self,
_run_id: &str,
_worker_id: &str,
_lease_token: &str,
) -> Result<Option<DurableCheckpointView>, Self::Error> {
Self::unsupported("read_checkpoint_with_lease")
}
fn save_checkpoint_with_lease(
&self,
_checkpoint: DurableCheckpointInput,
_worker_id: &str,
_lease_token: &str,
) -> Result<DurableCheckpointView, Self::Error> {
Self::unsupported("save_checkpoint_with_lease")
}
fn save_checkpoint_with_runtime_and_lease(
&self,
_commit: DurableCheckpointRuntimeCommit,
_worker_id: &str,
_lease_token: &str,
) -> Result<DurableCheckpointView, Self::Error> {
Self::unsupported("save_checkpoint_with_runtime_and_lease")
}
fn record_reconciliation_result(
&self,
_run_id: &str,
_phase: &str,
_external_id: &str,
_step: i64,
_attempt: i64,
_messages: Value,
) -> Result<DurableCheckpointView, Self::Error> {
Self::unsupported("record_reconciliation_result")
}
fn create_approval(
&self,
_approval: DurableApprovalInput,
) -> Result<DurableApprovalView, Self::Error> {
Self::unsupported("create_approval")
}
fn get_approval(
&self,
_approval_id: &str,
) -> Result<Option<DurableApprovalView>, Self::Error> {
Self::unsupported("get_approval")
}
fn list_approvals_for_run(
&self,
_run_id: &str,
) -> Result<Vec<DurableApprovalView>, Self::Error> {
Self::unsupported("list_approvals_for_run")
}
fn get_approval_for_run_call(
&self,
_run_id: &str,
_tool_call_id: &str,
) -> Result<Option<DurableApprovalView>, Self::Error> {
Self::unsupported("get_approval_for_run_call")
}
fn resolve_approval(
&self,
_resolution: DurableApprovalResolution,
) -> Result<DurableApprovalView, Self::Error> {
Self::unsupported("resolve_approval")
}
fn cancel_pending_approvals(&self, _run_id: &str) -> Result<usize, Self::Error> {
Self::unsupported("cancel_pending_approvals")
}
fn queue_approved_run(&self, _approval_id: &str) -> Result<DurableRunView, Self::Error> {
Self::unsupported("queue_approved_run")
}
fn finish_run_with_runtime(
&self,
command: DurableFinishCommand,
) -> Result<DurableRunView, Self::Error> {
let mut state = self.lock()?;
let expected_lease = command.lease.as_ref();
if let Some(lease) = expected_lease {
let actual = state
.leases
.get(&command.run_id)
.ok_or_else(|| MemoryError::NotFound(format!("lease {}", command.run_id)))?;
if actual.worker_id != lease.worker_id || actual.lease_token != lease.lease_token {
return Err(MemoryError::Conflict(
"finish lease fencing identity 不匹配".to_owned(),
));
}
} else if state.leases.contains_key(&command.run_id) {
return Err(MemoryError::Conflict(
"finish 不能绕过 active lease".to_owned(),
));
}
let runtime_id = state
.runtime_ids
.get(&command.run_id)
.ok_or_else(|| MemoryError::NotFound(format!("run {}", command.run_id)))?;
if runtime_id != &command.runtime_id {
return Err(MemoryError::Invalid(
"finish runtime identity 不一致".to_owned(),
));
}
Self::commit_snapshot(
&mut state,
&command.runtime_id,
command.expected_runtime_revision,
&command.snapshot,
&command.events,
)?;
let status = Self::terminal_status(command.target);
let (session_id, updated_at, finished) = {
let run = state
.runs
.get_mut(&command.run_id)
.ok_or_else(|| MemoryError::NotFound(format!("run {}", command.run_id)))?;
run.status = status.to_owned();
run.output = command.output;
run.revision = command
.snapshot
.run(&command.run_id)
.map_or(run.revision, |snapshot| {
i64::try_from(snapshot.revision).unwrap_or(i64::MAX)
});
run.updated_at = Self::now_ms();
(run.session_id.clone(), run.updated_at, run.clone())
};
state.leases.remove(&command.run_id);
if let Some(session) = state.sessions.get_mut(&session_id) {
session.status = status.to_owned();
session.updated_at = updated_at;
}
Ok(finished)
}
fn mark_cancelled_with_lease(
&self,
_run_id: &str,
_worker_id: &str,
_lease_token: &str,
_output: Option<Value>,
) -> Result<DurableRunView, Self::Error> {
Self::unsupported("mark_cancelled_with_lease")
}
fn mark_cancelled(
&self,
_run_id: &str,
_output: Option<Value>,
) -> Result<DurableRunView, Self::Error> {
Self::unsupported("mark_cancelled")
}
fn recover_expired_run(&self, run_id: &str) -> Result<DurableRunView, Self::Error> {
let mut state = self.lock()?;
if let Some(lease) = state.leases.get(run_id)
&& lease.lease_expires_at > Self::now_ms()
{
return Err(MemoryError::Conflict("lease 尚未过期".to_owned()));
}
state.leases.remove(run_id);
let run = state
.runs
.get_mut(run_id)
.ok_or_else(|| MemoryError::NotFound(format!("run {run_id}")))?;
run.status = "reconciling".to_owned();
run.updated_at = Self::now_ms();
Ok(run.clone())
}
fn recover_expired_run_with_runtime(
&self,
commit: DurableRecoveryCommit,
) -> Result<DurableRunView, Self::Error> {
let mut state = self.lock()?;
if let Some(lease) = state.leases.get(&commit.run_id)
&& lease.lease_expires_at > Self::now_ms()
{
return Err(MemoryError::Conflict("lease 尚未过期".to_owned()));
}
let runtime_id = state
.runtime_ids
.get(&commit.run_id)
.ok_or_else(|| MemoryError::NotFound(format!("run {}", commit.run_id)))?;
if runtime_id != &commit.runtime_id {
return Err(MemoryError::Invalid(
"recovery runtime identity 不一致".to_owned(),
));
}
Self::commit_snapshot(
&mut state,
&commit.runtime_id,
commit.expected_runtime_revision,
&commit.snapshot,
&commit.events,
)?;
state.leases.remove(&commit.run_id);
let (session_id, updated_at, recovered) = {
let run = state
.runs
.get_mut(&commit.run_id)
.ok_or_else(|| MemoryError::NotFound(format!("run {}", commit.run_id)))?;
run.status = "reconciling".to_owned();
run.updated_at = Self::now_ms();
(run.session_id.clone(), run.updated_at, run.clone())
};
if let Some(session) = state.sessions.get_mut(&session_id) {
session.status = "reconciling".to_owned();
session.updated_at = updated_at;
}
Ok(recovered)
}
fn upsert_external_session(
&self,
_session: DurableExternalSessionInput,
) -> Result<DurableExternalSessionView, Self::Error> {
Self::unsupported("upsert_external_session")
}
fn update_external_session(
&self,
_id: &str,
_external_id: &str,
_status: &str,
_metadata: Value,
) -> Result<DurableExternalSessionView, Self::Error> {
Self::unsupported("update_external_session")
}
fn create_tool_call(
&self,
_call: DurableToolCallInput,
) -> Result<DurableToolCallView, Self::Error> {
Self::unsupported("create_tool_call")
}
fn complete_tool_call(
&self,
_call_id: &str,
_status: &str,
_result: Value,
) -> Result<DurableToolCallView, Self::Error> {
Self::unsupported("complete_tool_call")
}
fn get_tool_call(
&self,
_call_id: &str,
) -> Result<Option<DurableToolCallView>, Self::Error> {
Self::unsupported("get_tool_call")
}
fn list_tool_calls_for_run(
&self,
_run_id: &str,
) -> Result<Vec<DurableToolCallView>, Self::Error> {
Self::unsupported("list_tool_calls_for_run")
}
fn get_external_session(
&self,
_id: &str,
) -> Result<Option<DurableExternalSessionView>, Self::Error> {
Self::unsupported("get_external_session")
}
fn list_external_sessions(
&self,
_statuses: &[&str],
_run_id: Option<&str>,
_limit: usize,
) -> Result<Vec<DurableExternalSessionView>, Self::Error> {
Self::unsupported("list_external_sessions")
}
fn load_runtime_snapshot(
&self,
runtime_id: &str,
) -> Result<Option<RuntimeSnapshot>, Self::Error> {
Ok(self.lock()?.snapshots.get(runtime_id).cloned())
}
fn commit_runtime_snapshot(
&self,
runtime_id: &str,
expected_revision: Option<u64>,
snapshot: &RuntimeSnapshot,
events: &[RuntimeEvent],
) -> Result<(), Self::Error> {
let mut state = self.lock()?;
Self::commit_snapshot(&mut state, runtime_id, expected_revision, snapshot, events)
}
}
fn fixture_bundle() -> DurableRunBundle {
let runtime_id = "memory-runtime".to_owned();
let run_id = "memory-run".to_owned();
let session_id = "memory-session".to_owned();
let run = RunSnapshot::try_new(&run_id, "memory-agent", "memory task", 1)
.expect("valid run snapshot");
let mut snapshot = RuntimeSnapshot::try_new(&runtime_id).expect("valid runtime snapshot");
snapshot.runs.push(run);
DurableRunBundle {
session: DurableSessionInput {
id: session_id.clone(),
agent_id: Some("memory-agent".to_owned()),
status: "queued".to_owned(),
metadata: json!({"adapter":"memory"}),
},
run: DurableRunInput {
id: run_id,
session_id,
status: "queued".to_owned(),
input: json!({"task":"memory task"}),
},
runtime_id,
snapshot,
events: Vec::new(),
}
}
#[test]
fn generic_durable_runtime_runs_without_sqlite_adapter() {
let facade = DurableRuntime::new(InMemoryDurableStore::default());
let bundle = fixture_bundle();
let expected = DurableBundleResult {
session_id: "memory-session".to_owned(),
run_id: "memory-run".to_owned(),
runtime_id: "memory-runtime".to_owned(),
};
assert_eq!(
facade.create_run_bundle(bundle).expect("memory bundle"),
expected
);
assert_eq!(
facade
.get_session("memory-session")
.expect("memory session")
.unwrap()
.status,
"queued"
);
assert_eq!(
facade
.get_run("memory-run")
.expect("memory run")
.unwrap()
.status,
"queued"
);
let claimed = facade
.claim_run_with_lease(
"memory-run",
"memory-worker",
"memory-token",
Duration::from_secs(30),
)
.expect("memory claim");
assert_eq!(claimed.run.status, "running");
assert_eq!(claimed.lease.attempt, 1);
let released = facade
.release_run_lease("memory-run", "memory-worker", "memory-token")
.expect("memory release");
assert_eq!(released.status, "reconciling");
facade.requeue_safe_run("memory-run").expect("safe requeue");
let claimed = facade
.claim_run_with_lease(
"memory-run",
"memory-worker",
"memory-token-2",
Duration::from_secs(30),
)
.expect("second memory claim");
assert_eq!(claimed.lease.attempt, 2);
// Build a real Core event so the fake exercises the same CAS shape as
// SQLite: one event advances one runtime revision.
let before = facade
.load_runtime_snapshot("memory-runtime")
.expect("memory snapshot")
.unwrap();
let started = RuntimeEvent::status_changed(
"memory-runtime",
before.revision() + 1,
2,
"memory-run",
RuntimeEventKind::RunStarted,
)
.expect("started event");
let running = reduce(&before, &started).expect("running snapshot");
facade
.commit_runtime_snapshot(
"memory-runtime",
Some(before.revision()),
&running,
std::slice::from_ref(&started),
)
.expect("memory CAS");
let stale = facade.commit_runtime_snapshot(
"memory-runtime",
Some(before.revision()),
&running,
std::slice::from_ref(&started),
);
assert!(matches!(stale, Err(MemoryError::Conflict(_))));
let completed = RuntimeEvent::completed(
"memory-runtime",
running.revision() + 1,
3,
"memory-run",
"done",
)
.expect("completed event");
let terminal = reduce(&running, &completed).expect("terminal snapshot");
let finished = facade
.finish_run_with_runtime(DurableFinishCommand {
run_id: "memory-run".to_owned(),
lease: Some(DurableLeaseIdentity {
worker_id: "memory-worker".to_owned(),
lease_token: "memory-token-2".to_owned(),
}),
target: DurableFinishTarget::Completed,
output: Some(json!({"text":"done"})),
runtime_id: "memory-runtime".to_owned(),
expected_runtime_revision: Some(running.revision()),
snapshot: terminal,
events: vec![completed],
guard: DurableFinishGuard::None,
})
.expect("memory finish");
assert_eq!(finished.status, "completed");
assert!(
facade
.get_run_lease("memory-run")
.expect("lease query")
.is_none()
);
assert_eq!(
facade
.load_runtime_snapshot("memory-runtime")
.expect("terminal snapshot")
.unwrap()
.run("memory-run")
.expect("terminal run")
.status(),
agent_runtime_core::RunStatus::Completed
);
}
#[test]
fn memory_adapter_recovers_an_expired_lease_without_sqlite() {
let facade = DurableRuntime::new(InMemoryDurableStore::default());
facade
.create_run_bundle(fixture_bundle())
.expect("memory bundle");
facade
.claim_run_with_lease(
"memory-run",
"recovery-worker",
"recovery-token",
Duration::ZERO,
)
.expect("expired memory claim");
let recovered = facade
.recover_expired_run("memory-run")
.expect("memory recovery");
assert_eq!(recovered.status, "reconciling");
assert!(
facade
.get_run_lease("memory-run")
.expect("lease query")
.is_none()
);
}
}