use std::sync::Arc;
use serde::{Deserialize, Serialize};
use zeph_durable::{DurableContext, DurableError, DurableHandle, DurablePromise, PromiseId};
use zeroize::Zeroizing;
use crate::error::SubAgentError;
use crate::state::SubAgentState;
const RESOLVER_TOKEN_LEN: usize = 32;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SubagentResult {
pub task_id: String,
pub output: String,
pub error: Option<String>,
pub state: SubAgentState,
}
impl SubagentResult {
#[must_use]
pub fn ok(task_id: impl Into<String>, output: impl Into<String>) -> Self {
Self {
task_id: task_id.into(),
output: output.into(),
error: None,
state: SubAgentState::Completed,
}
}
#[must_use]
pub fn err(task_id: impl Into<String>, error: impl Into<String>) -> Self {
Self {
task_id: task_id.into(),
output: String::new(),
error: Some(error.into()),
state: SubAgentState::Failed,
}
}
}
pub struct DurableResolverSeat {
pub handle: Arc<DurableHandle>,
pub promise_id: PromiseId,
pub token: Zeroizing<[u8; RESOLVER_TOKEN_LEN]>,
}
pub async fn make_durable_promise(
ctx: &DurableContext,
) -> Result<(DurablePromise<SubagentResult>, Option<DurableResolverSeat>), DurableError> {
let promise = ctx.promise::<SubagentResult>().await?;
let seat = if let Some(token) = promise.resolver_token() {
let handle = Arc::new(ctx.resolver_handle());
Some(DurableResolverSeat {
handle,
promise_id: promise.id(),
token: Zeroizing::new(*token),
})
} else {
None
};
Ok((promise, seat))
}
pub async fn await_durable_subagent(
ctx: &DurableContext,
execution_id: zeph_durable::ExecutionId,
promise: DurablePromise<SubagentResult>,
) -> Result<SubagentResult, SubAgentError> {
let promise_id = promise.id();
let exec_uuid = execution_id.as_uuid();
let span = tracing::info_span!(
"subagent.durable.await",
execution_id = %exec_uuid,
promise_id = %promise_id.as_uuid(),
);
async move {
ctx.await_promise(promise)
.await
.map_err(|e| SubAgentError::Durable(e.to_string()))
}
.instrument(span)
.await
}
pub async fn try_replay_durable_subagent(
ctx: &DurableContext,
promise: &DurablePromise<SubagentResult>,
) -> Result<Option<SubagentResult>, SubAgentError> {
ctx.take_resolved_promise(promise.id())
.await
.map_err(|e| SubAgentError::Durable(e.to_string()))
}
#[tracing::instrument(
name = "subagent.durable.resolve",
skip(seat, loop_result),
fields(promise_id = %seat.promise_id.as_uuid())
)]
pub async fn resolve_durable_promise(
seat: DurableResolverSeat,
task_id: &str,
loop_result: &Result<String, SubAgentError>,
) {
let result = match loop_result {
Ok(output) => SubagentResult::ok(task_id, output.as_str()),
Err(e) => SubagentResult::err(task_id, e.to_string()),
};
if let Err(e) = seat
.handle
.resolve(seat.promise_id, &seat.token, result)
.await
{
tracing::warn!(
task_id,
promise_id = %seat.promise_id.as_uuid(),
error = %e,
"durable: failed to resolve subagent promise — child result lost for durable replay"
);
}
}
use tracing::Instrument as _;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subagent_result_ok_fields() {
let r = SubagentResult::ok("t1", "hello");
assert_eq!(r.task_id, "t1");
assert_eq!(r.output, "hello");
assert!(r.error.is_none());
assert_eq!(r.state, SubAgentState::Completed);
}
#[test]
fn subagent_result_err_fields() {
let r = SubagentResult::err("t2", "timeout");
assert_eq!(r.task_id, "t2");
assert_eq!(r.output, "");
assert_eq!(r.error.as_deref(), Some("timeout"));
assert_eq!(r.state, SubAgentState::Failed);
}
#[test]
fn subagent_result_roundtrips_json() {
let original = SubagentResult::ok("task-42", "some output");
let json = serde_json::to_string(&original).unwrap();
let decoded: SubagentResult = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.task_id, original.task_id);
assert_eq!(decoded.output, original.output);
assert_eq!(decoded.state, original.state);
}
#[test]
fn resolver_seat_token_is_zeroizing() {
let token = Zeroizing::new([0u8; RESOLVER_TOKEN_LEN]);
let _ = token;
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn durable_promise_resolve_and_await_roundtrip() {
use std::sync::Arc;
use zeph_durable::{
DurableBackendEnum, DurableConfig, DurableContext, ExecutionId, ExecutionKind,
JournalWriter, LocalBackend,
};
let exec_id = ExecutionId::new();
let config = DurableConfig {
journal_flush_interval_ms: 5,
journal_ack_timeout_ms: 2000,
..DurableConfig::default()
};
let local = Arc::new(LocalBackend::open(":memory:", 1_048_576).await.unwrap());
local.init().await.unwrap();
local
.open_execution(exec_id, ExecutionKind::AgentTurn)
.await
.unwrap();
let (writer, handle) = JournalWriter::new(local.clone(), &config);
let _writer_task = tokio::spawn(writer.run());
let backend = Arc::new(DurableBackendEnum::Local(local.clone()));
let ctx = DurableContext::new(
exec_id,
ExecutionKind::AgentTurn,
false,
backend,
handle,
&config,
);
let (promise, seat_opt) = make_durable_promise(&ctx).await.unwrap();
let seat = seat_opt.expect("fresh execution must yield a resolver seat");
let promise_id = promise.id();
let loop_result: Result<String, crate::error::SubAgentError> =
Ok("agent output".to_owned());
resolve_durable_promise(seat, "task-rt-01", &loop_result).await;
let result = await_durable_subagent(&ctx, exec_id, promise)
.await
.unwrap();
assert_eq!(result.task_id, "task-rt-01");
assert_eq!(result.output, "agent output");
assert!(result.error.is_none());
assert_eq!(result.state, crate::state::SubAgentState::Completed);
let _ = promise_id;
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn try_replay_durable_subagent_sees_already_resolved_promise_on_resume() {
use std::sync::Arc;
use zeph_durable::{
DurableBackendEnum, DurableConfig, DurableContext, ExecutionId, ExecutionKind,
JournalWriter, LocalBackend,
};
let exec_id = ExecutionId::new();
let config = DurableConfig {
journal_flush_interval_ms: 5,
journal_ack_timeout_ms: 2000,
..DurableConfig::default()
};
let local = Arc::new(LocalBackend::open(":memory:", 1_048_576).await.unwrap());
local.init().await.unwrap();
local
.open_execution(exec_id, ExecutionKind::AgentTurn)
.await
.unwrap();
let (writer, handle) = JournalWriter::new(local.clone(), &config);
let _writer_task = tokio::spawn(writer.run());
let backend = Arc::new(DurableBackendEnum::Local(local.clone()));
let ctx1 = DurableContext::new(
exec_id,
ExecutionKind::AgentTurn,
false,
backend.clone(),
handle.clone(),
&config,
);
let (_promise1, seat_opt) = make_durable_promise(&ctx1).await.unwrap();
let seat = seat_opt.expect("fresh execution must yield a resolver seat");
let loop_result: Result<String, crate::error::SubAgentError> =
Ok("finished before crash".to_owned());
resolve_durable_promise(seat, "task-resumed-01", &loop_result).await;
let ctx2 = DurableContext::new(
exec_id,
ExecutionKind::AgentTurn,
true,
backend,
handle,
&config,
);
let (promise2, seat_opt2) = make_durable_promise(&ctx2).await.unwrap();
assert!(
promise2.is_resumed(),
"test setup: run 2 must observe a resumed promise, not a fresh one"
);
assert!(seat_opt2.is_none());
let replayed = try_replay_durable_subagent(&ctx2, &promise2).await.unwrap();
let result =
replayed.expect("child already resolved before the crash — must replay, not spawn");
assert_eq!(result.task_id, "task-resumed-01");
assert_eq!(result.output, "finished before crash");
assert_eq!(result.state, crate::state::SubAgentState::Completed);
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn try_replay_durable_subagent_returns_none_when_still_pending() {
use std::sync::Arc;
use zeph_durable::{
DurableBackendEnum, DurableConfig, DurableContext, ExecutionId, ExecutionKind,
JournalWriter, LocalBackend,
};
let exec_id = ExecutionId::new();
let config = DurableConfig {
journal_flush_interval_ms: 5,
journal_ack_timeout_ms: 2000,
..DurableConfig::default()
};
let local = Arc::new(LocalBackend::open(":memory:", 1_048_576).await.unwrap());
local.init().await.unwrap();
local
.open_execution(exec_id, ExecutionKind::AgentTurn)
.await
.unwrap();
let (writer, handle) = JournalWriter::new(local.clone(), &config);
let _writer_task = tokio::spawn(writer.run());
let backend = Arc::new(DurableBackendEnum::Local(local.clone()));
let ctx1 = DurableContext::new(
exec_id,
ExecutionKind::AgentTurn,
false,
backend.clone(),
handle.clone(),
&config,
);
let (_promise1, seat_opt) = make_durable_promise(&ctx1).await.unwrap();
assert!(seat_opt.is_some());
let ctx2 = DurableContext::new(
exec_id,
ExecutionKind::AgentTurn,
true,
backend,
handle,
&config,
);
let (promise2, seat_opt2) = make_durable_promise(&ctx2).await.unwrap();
assert!(promise2.is_resumed());
assert!(seat_opt2.is_none());
let replayed = try_replay_durable_subagent(&ctx2, &promise2).await.unwrap();
assert!(
replayed.is_none(),
"still-pending resumed promise must return None without parking"
);
}
}