zeph_subagent/durable.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Durable promise adapter for subagent spawn/await (spec-064 §P4, INV-9, FR-DE-05).
5//!
6//! This module is a thin Layer-2 adapter. It wires the parent's [`DurableContext`] promise
7//! lifecycle to the subagent spawn/collect path. Domain meaning ("subagent result") lives here;
8//! the cryptographic and journaling mechanics live in `zeph-durable` (Layer 0).
9//!
10//! # Scope boundary
11//!
12//! This adapter covers the **finished-child replay** case only: when a parent resumes after a
13//! crash and the child already resolved its promise, `await_durable_subagent` returns the
14//! journaled `SubagentResult` immediately without re-spawning the child (spec §1038, acceptance
15//! test item 4).
16//!
17//! The still-running-child-on-parent-crash case is intentionally out of scope. Only the BLAKE3
18//! hash of the resolver token is persisted (INV-9); the raw 32-byte token is `Zeroizing` and
19//! never stored. A crashed parent therefore cannot re-mint a valid token for an in-flight child's
20//! promise row. This is the direct consequence of INV-9's hash-only persistence guarantee —
21//! inventing a token-recovery path would violate INV-9. The general crash-recovery gap is
22//! declared out of v1 scope in spec §862 and §1226.
23//!
24//! # INV-9 channel rule
25//!
26//! The [`DurableResolverSeat`] (holding the backend handle + token) is carried through a new
27//! field on `SpawnContext::durable_resolver`. It is handed to the spawned background task only.
28//! It MUST NOT be accessible from the child's tool executor or LLM surface at any point.
29//!
30//! # Gate pattern
31//!
32//! The gate check lives at the call site in `zeph-core` (where `DurableConfig` and the parent
33//! `DurableContext` are available). When `durable.enabled && durable.subagent`, the call site:
34//!
35//! 1. Calls [`make_durable_promise`] to create the promise and optionally a resolver seat.
36//! 2. Places the seat in [`crate::manager::SpawnContext::durable_resolver`] before spawning.
37//! 3. Calls [`await_durable_subagent`] instead of [`crate::SubAgentManager::collect`].
38//!
39//! When either flag is `false`, `SpawnContext::durable_resolver` is `None` and the plain
40//! `spawn`/`collect` path runs byte-identically to today (opt-in, zero overhead when disabled).
41
42use std::sync::Arc;
43
44use serde::{Deserialize, Serialize};
45use zeph_durable::{DurableContext, DurableError, DurableHandle, DurablePromise, PromiseId};
46use zeroize::Zeroizing;
47
48use crate::error::SubAgentError;
49use crate::state::SubAgentState;
50
51/// Token length mirrors `zeph_durable::promise::RESOLVER_TOKEN_LEN` (32 bytes).
52const RESOLVER_TOKEN_LEN: usize = 32;
53
54/// The payload stored in the durable promise for a subagent's terminal result.
55///
56/// Carries both the success and failure cases so a resumed parent can reconstruct the
57/// exact control outcome (spec reconciliation: §884 — live run and replay must diverge on
58/// neither the output nor the error path).
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct SubagentResult {
61 /// The task ID assigned at spawn time, for correlation.
62 pub task_id: String,
63 /// Terminal output text on success (`Completed` state). Empty when `error` is `Some`.
64 pub output: String,
65 /// Error detail on failure or cancellation (`Failed`/`Canceled` state).
66 pub error: Option<String>,
67 /// Terminal lifecycle state of the subagent.
68 pub state: SubAgentState,
69}
70
71impl SubagentResult {
72 /// Build a successful result from the agent loop's output string.
73 #[must_use]
74 pub fn ok(task_id: impl Into<String>, output: impl Into<String>) -> Self {
75 Self {
76 task_id: task_id.into(),
77 output: output.into(),
78 error: None,
79 state: SubAgentState::Completed,
80 }
81 }
82
83 /// Build a failed result carrying the error reason so replay can reconstruct the same outcome.
84 #[must_use]
85 pub fn err(task_id: impl Into<String>, error: impl Into<String>) -> Self {
86 Self {
87 task_id: task_id.into(),
88 output: String::new(),
89 error: Some(error.into()),
90 state: SubAgentState::Failed,
91 }
92 }
93}
94
95/// The out-of-band resolver seat carried from parent to child background task (INV-9).
96///
97/// Held exclusively inside the spawned background task; never reachable from the child's
98/// tool executor or LLM surface. `Zeroizing` ensures the raw token bytes are wiped on drop.
99pub struct DurableResolverSeat {
100 /// Shared backend handle — cheap clone, used only to call `resolve`.
101 pub handle: Arc<DurableHandle>,
102 /// Promise identifier matching the parent's program position.
103 pub promise_id: PromiseId,
104 /// The raw 32-byte resolver token (zeroized on drop, never stored).
105 pub token: Zeroizing<[u8; RESOLVER_TOKEN_LEN]>,
106}
107
108/// Create a durable promise in the parent's execution and return the resolver seat for the child.
109///
110/// Calls `ctx.promise::<SubagentResult>()` to occupy a deterministic program position so a
111/// resumed parent re-derives the same [`PromiseId`] and re-attaches to the pending row rather
112/// than minting an orphan.
113///
114/// Returns `(promise, seat)` where:
115/// - `promise` is passed to [`await_durable_subagent`] after the child is spawned.
116/// - `seat` carries the resolver token and must be handed to the child's background task
117/// (via `SpawnContext::durable_resolver`). On a resumed parent the promise is already
118/// created, so `seat` is `None` — the child's original token was delivered before the crash
119/// and is unrecoverable (INV-9).
120///
121/// # Errors
122///
123/// Propagates [`DurableError`] if the promise row cannot be read or inserted, or if the
124/// per-execution step cap is exceeded.
125pub async fn make_durable_promise(
126 ctx: &DurableContext,
127) -> Result<(DurablePromise<SubagentResult>, Option<DurableResolverSeat>), DurableError> {
128 let promise = ctx.promise::<SubagentResult>().await?;
129 let seat = if let Some(token) = promise.resolver_token() {
130 let handle = Arc::new(ctx.resolver_handle());
131 Some(DurableResolverSeat {
132 handle,
133 promise_id: promise.id(),
134 token: Zeroizing::new(*token),
135 })
136 } else {
137 // Resumed: original token was delivered before the crash; cannot recover.
138 None
139 };
140 Ok((promise, seat))
141}
142
143/// Await a durable promise for a subagent result, with an adapter-level tracing span.
144///
145/// On a fresh run this parks (in-process notify or poll) until the child's background task
146/// calls [`resolve_durable_promise`]. On a resumed parent it returns the journaled
147/// `SubagentResult` immediately if the child already resolved (spec §1038). In either case,
148/// replay is transparent to the caller.
149///
150/// # Errors
151///
152/// Propagates [`DurableError`] if the promise row is missing (pruned) or the payload cannot
153/// be decoded.
154pub async fn await_durable_subagent(
155 ctx: &DurableContext,
156 execution_id: zeph_durable::ExecutionId,
157 promise: DurablePromise<SubagentResult>,
158) -> Result<SubagentResult, SubAgentError> {
159 let promise_id = promise.id();
160 let exec_uuid = execution_id.as_uuid();
161 let span = tracing::info_span!(
162 "subagent.durable.await",
163 execution_id = %exec_uuid,
164 promise_id = %promise_id.as_uuid(),
165 );
166 async move {
167 ctx.await_promise(promise)
168 .await
169 .map_err(|e| SubAgentError::Durable(e.to_string()))
170 }
171 .instrument(span)
172 .await
173}
174
175/// Called from the child's background task after the agent loop terminates.
176///
177/// Builds a [`SubagentResult`] from the loop's terminal outcome and resolves the promise via
178/// [`DurableHandle::resolve`]. On a wrong token or missing promise row the error is logged at
179/// `warn` level and swallowed — the child has already finished and cannot retry.
180///
181/// The INV-9 channel rule is enforced by the caller: `seat` must be consumed here and never
182/// forwarded to any tool executor or LLM surface.
183#[tracing::instrument(
184 name = "subagent.durable.resolve",
185 skip(seat, loop_result),
186 fields(promise_id = %seat.promise_id.as_uuid())
187)]
188pub async fn resolve_durable_promise(
189 seat: DurableResolverSeat,
190 task_id: &str,
191 loop_result: &Result<String, SubAgentError>,
192) {
193 let result = match loop_result {
194 Ok(output) => SubagentResult::ok(task_id, output.as_str()),
195 Err(e) => SubagentResult::err(task_id, e.to_string()),
196 };
197 if let Err(e) = seat
198 .handle
199 .resolve(seat.promise_id, &seat.token, result)
200 .await
201 {
202 tracing::warn!(
203 task_id,
204 promise_id = %seat.promise_id.as_uuid(),
205 error = %e,
206 "durable: failed to resolve subagent promise — child result lost for durable replay"
207 );
208 }
209}
210
211// Bring `Instrument` trait into scope for `.instrument(span)`.
212use tracing::Instrument as _;
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn subagent_result_ok_fields() {
220 let r = SubagentResult::ok("t1", "hello");
221 assert_eq!(r.task_id, "t1");
222 assert_eq!(r.output, "hello");
223 assert!(r.error.is_none());
224 assert_eq!(r.state, SubAgentState::Completed);
225 }
226
227 #[test]
228 fn subagent_result_err_fields() {
229 let r = SubagentResult::err("t2", "timeout");
230 assert_eq!(r.task_id, "t2");
231 assert_eq!(r.output, "");
232 assert_eq!(r.error.as_deref(), Some("timeout"));
233 assert_eq!(r.state, SubAgentState::Failed);
234 }
235
236 #[test]
237 fn subagent_result_roundtrips_json() {
238 let original = SubagentResult::ok("task-42", "some output");
239 let json = serde_json::to_string(&original).unwrap();
240 let decoded: SubagentResult = serde_json::from_str(&json).unwrap();
241 assert_eq!(decoded.task_id, original.task_id);
242 assert_eq!(decoded.output, original.output);
243 assert_eq!(decoded.state, original.state);
244 }
245
246 #[test]
247 fn resolver_seat_token_is_zeroizing() {
248 // Verify the struct compiles with Zeroizing token field and can be constructed.
249 let token = Zeroizing::new([0u8; RESOLVER_TOKEN_LEN]);
250 // DurableHandle requires a backend which we can't build in a unit test.
251 // We only verify the type and field layout here.
252 let _ = token;
253 }
254
255 /// Verify the full resolve → `await_promise` round-trip using an in-memory backend.
256 ///
257 /// This tests that:
258 /// 1. `make_durable_promise` returns a fresh promise + seat on first call.
259 /// 2. `resolve_durable_promise` stores the payload via the seat's token.
260 /// 3. `await_durable_subagent` on a resumed context returns the stored `SubagentResult`.
261 //
262 // Opens a real `LocalBackend` pool via `:memory:`, which is SQLite-specific: under
263 // `--features postgres` (reachable here through workspace-level feature unification, even
264 // though this crate has no `postgres` feature of its own — zeph-scheduler/zeph-orchestration's
265 // `postgres` features enable `zeph-db/postgres` directly), `DbConfig::connect()` takes
266 // cfg-priority and routes `:memory:` into `connect_postgres`, which fails to parse it as a
267 // Postgres URL. See #5608.
268 #[cfg(feature = "sqlite")]
269 #[tokio::test]
270 async fn durable_promise_resolve_and_await_roundtrip() {
271 use std::sync::Arc;
272 use zeph_durable::{
273 DurableBackendEnum, DurableConfig, DurableContext, ExecutionId, ExecutionKind,
274 JournalWriter, LocalBackend,
275 };
276
277 let exec_id = ExecutionId::new();
278 let config = DurableConfig {
279 journal_flush_interval_ms: 5,
280 journal_ack_timeout_ms: 2000,
281 ..DurableConfig::default()
282 };
283
284 let local = Arc::new(LocalBackend::open(":memory:", 1_048_576).await.unwrap());
285 local.init().await.unwrap();
286 local
287 .open_execution(exec_id, ExecutionKind::AgentTurn)
288 .await
289 .unwrap();
290
291 let (writer, handle) = JournalWriter::new(local.clone(), &config);
292 let _writer_task = tokio::spawn(writer.run());
293
294 let backend = Arc::new(DurableBackendEnum::Local(local.clone()));
295 let ctx = DurableContext::new(
296 exec_id,
297 ExecutionKind::AgentTurn,
298 false,
299 backend,
300 handle,
301 &config,
302 );
303
304 // Step 1: make_durable_promise returns a seat on first call.
305 let (promise, seat_opt) = make_durable_promise(&ctx).await.unwrap();
306 let seat = seat_opt.expect("fresh execution must yield a resolver seat");
307 let promise_id = promise.id();
308
309 // Step 2: resolve via seat (simulating child background task finish).
310 let loop_result: Result<String, crate::error::SubAgentError> =
311 Ok("agent output".to_owned());
312 resolve_durable_promise(seat, "task-rt-01", &loop_result).await;
313
314 // Step 3: await on the same context — must return the stored SubagentResult.
315 let result = await_durable_subagent(&ctx, exec_id, promise)
316 .await
317 .unwrap();
318 assert_eq!(result.task_id, "task-rt-01");
319 assert_eq!(result.output, "agent output");
320 assert!(result.error.is_none());
321 assert_eq!(result.state, crate::state::SubAgentState::Completed);
322 let _ = promise_id;
323 }
324}