Skip to main content

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}