202279c6d9
新增 Core、Engine、Runtime、SQLite、Provider、MCP、Skill、Codex、CLI 与 DAG crate 补齐 OpenAI endpoint 配置、Provider 实例/协议路由和统一工具权限边界 加入持久化、lease、checkpoint、reconciliation、审批恢复与消息历史回归 加入独立 workspace CI、依赖边界、能力集和 Fake Agent 测试脚本 同步建设计划、TODO、架构、测试与验收文档
1267 lines
43 KiB
Rust
1267 lines
43 KiB
Rust
//! 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 控制面 API;SQLite-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()
|
||
);
|
||
}
|
||
}
|