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, [`try_replay_durable_subagent`] returns the
14//! journaled `SubagentResult` immediately so the call site can skip re-spawning the child (spec
15//! §1038, acceptance 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. On a **fresh** run (`seat` is `Some`) it places the seat in
37//!    [`crate::manager::SpawnContext::durable_resolver`] before spawning, so the child resolves
38//!    the promise on exit via [`resolve_durable_promise`].
39//! 3. On a **resumed** run (`seat` is `None`) it calls [`try_replay_durable_subagent`] against
40//!    the re-derived promise. If the child already resolved, the call site replays the journaled
41//!    `SubagentResult` and skips `spawn` entirely — the fix this module exists for. If the
42//!    promise is still pending, the call site falls back to a plain spawn (documented "Scope
43//!    boundary" gap above: a genuinely in-flight child cannot be re-attached to after a crash).
44//!
45//! When `durable.enabled && durable.subagent` is `false`, `SpawnContext::durable_resolver` stays
46//! `None` and the plain `spawn`/`collect` path runs byte-identically to today (opt-in, zero
47//! overhead when disabled).
48
49use std::sync::Arc;
50
51use serde::{Deserialize, Serialize};
52use zeph_durable::{DurableContext, DurableError, DurableHandle, DurablePromise, PromiseId};
53use zeroize::Zeroizing;
54
55use crate::error::SubAgentError;
56use crate::state::SubAgentState;
57
58/// Token length mirrors `zeph_durable::promise::RESOLVER_TOKEN_LEN` (32 bytes).
59const RESOLVER_TOKEN_LEN: usize = 32;
60
61/// The payload stored in the durable promise for a subagent's terminal result.
62///
63/// Carries both the success and failure cases so a resumed parent can reconstruct the
64/// exact control outcome (spec reconciliation: §884 — live run and replay must diverge on
65/// neither the output nor the error path).
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct SubagentResult {
68    /// The task ID assigned at spawn time, for correlation.
69    pub task_id: String,
70    /// Terminal output text on success (`Completed` state). Empty when `error` is `Some`.
71    pub output: String,
72    /// Error detail on failure or cancellation (`Failed`/`Canceled` state).
73    pub error: Option<String>,
74    /// Terminal lifecycle state of the subagent.
75    pub state: SubAgentState,
76}
77
78impl SubagentResult {
79    /// Build a successful result from the agent loop's output string.
80    #[must_use]
81    pub fn ok(task_id: impl Into<String>, output: impl Into<String>) -> Self {
82        Self {
83            task_id: task_id.into(),
84            output: output.into(),
85            error: None,
86            state: SubAgentState::Completed,
87        }
88    }
89
90    /// Build a failed result carrying the error reason so replay can reconstruct the same outcome.
91    #[must_use]
92    pub fn err(task_id: impl Into<String>, error: impl Into<String>) -> Self {
93        Self {
94            task_id: task_id.into(),
95            output: String::new(),
96            error: Some(error.into()),
97            state: SubAgentState::Failed,
98        }
99    }
100}
101
102/// The out-of-band resolver seat carried from parent to child background task (INV-9).
103///
104/// Held exclusively inside the spawned background task; never reachable from the child's
105/// tool executor or LLM surface. `Zeroizing` ensures the raw token bytes are wiped on drop.
106pub struct DurableResolverSeat {
107    /// Shared backend handle — cheap clone, used only to call `resolve`.
108    pub handle: Arc<DurableHandle>,
109    /// Promise identifier matching the parent's program position.
110    pub promise_id: PromiseId,
111    /// The raw 32-byte resolver token (zeroized on drop, never stored).
112    pub token: Zeroizing<[u8; RESOLVER_TOKEN_LEN]>,
113}
114
115/// Create a durable promise in the parent's execution and return the resolver seat for the child.
116///
117/// Calls `ctx.promise::<SubagentResult>()` to occupy a deterministic program position so a
118/// resumed parent re-derives the same [`PromiseId`] and re-attaches to the pending row rather
119/// than minting an orphan.
120///
121/// Returns `(promise, seat)` where:
122/// - `promise` is passed to [`await_durable_subagent`] after the child is spawned.
123/// - `seat` carries the resolver token and must be handed to the child's background task
124///   (via `SpawnContext::durable_resolver`). On a resumed parent the promise is already
125///   created, so `seat` is `None` — the child's original token was delivered before the crash
126///   and is unrecoverable (INV-9).
127///
128/// # Errors
129///
130/// Propagates [`DurableError`] if the promise row cannot be read or inserted, or if the
131/// per-execution step cap is exceeded.
132pub async fn make_durable_promise(
133    ctx: &DurableContext,
134) -> Result<(DurablePromise<SubagentResult>, Option<DurableResolverSeat>), DurableError> {
135    let promise = ctx.promise::<SubagentResult>().await?;
136    let seat = if let Some(token) = promise.resolver_token() {
137        let handle = Arc::new(ctx.resolver_handle());
138        Some(DurableResolverSeat {
139            handle,
140            promise_id: promise.id(),
141            token: Zeroizing::new(*token),
142        })
143    } else {
144        // Resumed: original token was delivered before the crash; cannot recover.
145        None
146    };
147    Ok((promise, seat))
148}
149
150/// Await a durable promise for a subagent result, with an adapter-level tracing span.
151///
152/// On a fresh run this parks (in-process notify or poll) until the child's background task
153/// calls [`resolve_durable_promise`]. On a resumed parent it returns the journaled
154/// `SubagentResult` immediately if the child already resolved (spec §1038). In either case,
155/// replay is transparent to the caller.
156///
157/// # Errors
158///
159/// Propagates [`DurableError`] if the promise row is missing (pruned) or the payload cannot
160/// be decoded.
161pub async fn await_durable_subagent(
162    ctx: &DurableContext,
163    execution_id: zeph_durable::ExecutionId,
164    promise: DurablePromise<SubagentResult>,
165) -> Result<SubagentResult, SubAgentError> {
166    let promise_id = promise.id();
167    let exec_uuid = execution_id.as_uuid();
168    let span = tracing::info_span!(
169        "subagent.durable.await",
170        execution_id = %exec_uuid,
171        promise_id = %promise_id.as_uuid(),
172    );
173    async move {
174        ctx.await_promise(promise)
175            .await
176            .map_err(|e| SubAgentError::Durable(e.to_string()))
177    }
178    .instrument(span)
179    .await
180}
181
182/// Non-blocking check for a resumed subagent promise's journaled result (spec §1038).
183///
184/// Unlike [`await_durable_subagent`], this never parks: it performs a single backend read and
185/// returns `Ok(None)` immediately if the child has not resolved yet. Call sites that decide
186/// between replaying a journaled result and spawning a fresh child (see module docs, "Gate
187/// pattern") use this on a resumed promise (`promise.is_resumed()`) to avoid blocking the
188/// interactive path on a promise that may never resolve — its resolver token is unrecoverable
189/// (INV-9), so nothing can resolve it if the original child is gone.
190///
191/// # Errors
192///
193/// Propagates [`DurableError`] if the promise row is missing (pruned) or the payload cannot
194/// be decoded.
195pub async fn try_replay_durable_subagent(
196    ctx: &DurableContext,
197    promise: &DurablePromise<SubagentResult>,
198) -> Result<Option<SubagentResult>, SubAgentError> {
199    ctx.take_resolved_promise(promise.id())
200        .await
201        .map_err(|e| SubAgentError::Durable(e.to_string()))
202}
203
204/// Called from the child's background task after the agent loop terminates.
205///
206/// Builds a [`SubagentResult`] from the loop's terminal outcome and resolves the promise via
207/// [`DurableHandle::resolve`]. On a wrong token or missing promise row the error is logged at
208/// `warn` level and swallowed — the child has already finished and cannot retry.
209///
210/// The INV-9 channel rule is enforced by the caller: `seat` must be consumed here and never
211/// forwarded to any tool executor or LLM surface.
212#[tracing::instrument(
213    name = "subagent.durable.resolve",
214    skip(seat, loop_result),
215    fields(promise_id = %seat.promise_id.as_uuid())
216)]
217pub async fn resolve_durable_promise(
218    seat: DurableResolverSeat,
219    task_id: &str,
220    loop_result: &Result<String, SubAgentError>,
221) {
222    let result = match loop_result {
223        Ok(output) => SubagentResult::ok(task_id, output.as_str()),
224        Err(e) => SubagentResult::err(task_id, e.to_string()),
225    };
226    if let Err(e) = seat
227        .handle
228        .resolve(seat.promise_id, &seat.token, result)
229        .await
230    {
231        tracing::warn!(
232            task_id,
233            promise_id = %seat.promise_id.as_uuid(),
234            error = %e,
235            "durable: failed to resolve subagent promise — child result lost for durable replay"
236        );
237    }
238}
239
240// Bring `Instrument` trait into scope for `.instrument(span)`.
241use tracing::Instrument as _;
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn subagent_result_ok_fields() {
249        let r = SubagentResult::ok("t1", "hello");
250        assert_eq!(r.task_id, "t1");
251        assert_eq!(r.output, "hello");
252        assert!(r.error.is_none());
253        assert_eq!(r.state, SubAgentState::Completed);
254    }
255
256    #[test]
257    fn subagent_result_err_fields() {
258        let r = SubagentResult::err("t2", "timeout");
259        assert_eq!(r.task_id, "t2");
260        assert_eq!(r.output, "");
261        assert_eq!(r.error.as_deref(), Some("timeout"));
262        assert_eq!(r.state, SubAgentState::Failed);
263    }
264
265    #[test]
266    fn subagent_result_roundtrips_json() {
267        let original = SubagentResult::ok("task-42", "some output");
268        let json = serde_json::to_string(&original).unwrap();
269        let decoded: SubagentResult = serde_json::from_str(&json).unwrap();
270        assert_eq!(decoded.task_id, original.task_id);
271        assert_eq!(decoded.output, original.output);
272        assert_eq!(decoded.state, original.state);
273    }
274
275    #[test]
276    fn resolver_seat_token_is_zeroizing() {
277        // Verify the struct compiles with Zeroizing token field and can be constructed.
278        let token = Zeroizing::new([0u8; RESOLVER_TOKEN_LEN]);
279        // DurableHandle requires a backend which we can't build in a unit test.
280        // We only verify the type and field layout here.
281        let _ = token;
282    }
283
284    /// Verify the full resolve → `await_promise` round-trip using an in-memory backend.
285    ///
286    /// This tests that:
287    /// 1. `make_durable_promise` returns a fresh promise + seat on first call.
288    /// 2. `resolve_durable_promise` stores the payload via the seat's token.
289    /// 3. `await_durable_subagent` on a resumed context returns the stored `SubagentResult`.
290    //
291    // Opens a real `LocalBackend` pool via `:memory:`, which is SQLite-specific: under
292    // `--features postgres` (reachable here through workspace-level feature unification, even
293    // though this crate has no `postgres` feature of its own — zeph-scheduler/zeph-orchestration's
294    // `postgres` features enable `zeph-db/postgres` directly), `DbConfig::connect()` takes
295    // cfg-priority and routes `:memory:` into `connect_postgres`, which fails to parse it as a
296    // Postgres URL. See #5608.
297    #[cfg(feature = "sqlite")]
298    #[tokio::test]
299    async fn durable_promise_resolve_and_await_roundtrip() {
300        use std::sync::Arc;
301        use zeph_durable::{
302            DurableBackendEnum, DurableConfig, DurableContext, ExecutionId, ExecutionKind,
303            JournalWriter, LocalBackend,
304        };
305
306        let exec_id = ExecutionId::new();
307        let config = DurableConfig {
308            journal_flush_interval_ms: 5,
309            journal_ack_timeout_ms: 2000,
310            ..DurableConfig::default()
311        };
312
313        let local = Arc::new(LocalBackend::open(":memory:", 1_048_576).await.unwrap());
314        local.init().await.unwrap();
315        local
316            .open_execution(exec_id, ExecutionKind::AgentTurn)
317            .await
318            .unwrap();
319
320        let (writer, handle) = JournalWriter::new(local.clone(), &config);
321        let _writer_task = tokio::spawn(writer.run());
322
323        let backend = Arc::new(DurableBackendEnum::Local(local.clone()));
324        let ctx = DurableContext::new(
325            exec_id,
326            ExecutionKind::AgentTurn,
327            false,
328            backend,
329            handle,
330            &config,
331        );
332
333        // Step 1: make_durable_promise returns a seat on first call.
334        let (promise, seat_opt) = make_durable_promise(&ctx).await.unwrap();
335        let seat = seat_opt.expect("fresh execution must yield a resolver seat");
336        let promise_id = promise.id();
337
338        // Step 2: resolve via seat (simulating child background task finish).
339        let loop_result: Result<String, crate::error::SubAgentError> =
340            Ok("agent output".to_owned());
341        resolve_durable_promise(seat, "task-rt-01", &loop_result).await;
342
343        // Step 3: await on the same context — must return the stored SubagentResult.
344        let result = await_durable_subagent(&ctx, exec_id, promise)
345            .await
346            .unwrap();
347        assert_eq!(result.task_id, "task-rt-01");
348        assert_eq!(result.output, "agent output");
349        assert!(result.error.is_none());
350        assert_eq!(result.state, crate::state::SubAgentState::Completed);
351        let _ = promise_id;
352    }
353
354    /// #5944 regression: a resumed context (simulating a parent restart — a fresh
355    /// `DurableContext` over the same execution/backend, step counter reset to 0) whose child
356    /// already resolved the promise before the crash must see the journaled result via
357    /// `try_replay_durable_subagent` without parking.
358    #[cfg(feature = "sqlite")]
359    #[tokio::test]
360    async fn try_replay_durable_subagent_sees_already_resolved_promise_on_resume() {
361        use std::sync::Arc;
362        use zeph_durable::{
363            DurableBackendEnum, DurableConfig, DurableContext, ExecutionId, ExecutionKind,
364            JournalWriter, LocalBackend,
365        };
366
367        let exec_id = ExecutionId::new();
368        let config = DurableConfig {
369            journal_flush_interval_ms: 5,
370            journal_ack_timeout_ms: 2000,
371            ..DurableConfig::default()
372        };
373
374        let local = Arc::new(LocalBackend::open(":memory:", 1_048_576).await.unwrap());
375        local.init().await.unwrap();
376        local
377            .open_execution(exec_id, ExecutionKind::AgentTurn)
378            .await
379            .unwrap();
380
381        let (writer, handle) = JournalWriter::new(local.clone(), &config);
382        let _writer_task = tokio::spawn(writer.run());
383        let backend = Arc::new(DurableBackendEnum::Local(local.clone()));
384
385        // "Run 1": fresh promise created and resolved by the child before the parent crashes.
386        let ctx1 = DurableContext::new(
387            exec_id,
388            ExecutionKind::AgentTurn,
389            false,
390            backend.clone(),
391            handle.clone(),
392            &config,
393        );
394        let (_promise1, seat_opt) = make_durable_promise(&ctx1).await.unwrap();
395        let seat = seat_opt.expect("fresh execution must yield a resolver seat");
396        let loop_result: Result<String, crate::error::SubAgentError> =
397            Ok("finished before crash".to_owned());
398        resolve_durable_promise(seat, "task-resumed-01", &loop_result).await;
399
400        // "Run 2": simulates the restarted parent re-deriving the same promise position.
401        let ctx2 = DurableContext::new(
402            exec_id,
403            ExecutionKind::AgentTurn,
404            true,
405            backend,
406            handle,
407            &config,
408        );
409        let (promise2, seat_opt2) = make_durable_promise(&ctx2).await.unwrap();
410        assert!(
411            promise2.is_resumed(),
412            "test setup: run 2 must observe a resumed promise, not a fresh one"
413        );
414        assert!(seat_opt2.is_none());
415
416        let replayed = try_replay_durable_subagent(&ctx2, &promise2).await.unwrap();
417        let result =
418            replayed.expect("child already resolved before the crash — must replay, not spawn");
419        assert_eq!(result.task_id, "task-resumed-01");
420        assert_eq!(result.output, "finished before crash");
421        assert_eq!(result.state, crate::state::SubAgentState::Completed);
422    }
423
424    /// #5944: the still-pending resumed case must return `None` immediately rather than
425    /// parking — the caller falls back to a plain spawn (documented v1 scope gap).
426    #[cfg(feature = "sqlite")]
427    #[tokio::test]
428    async fn try_replay_durable_subagent_returns_none_when_still_pending() {
429        use std::sync::Arc;
430        use zeph_durable::{
431            DurableBackendEnum, DurableConfig, DurableContext, ExecutionId, ExecutionKind,
432            JournalWriter, LocalBackend,
433        };
434
435        let exec_id = ExecutionId::new();
436        let config = DurableConfig {
437            journal_flush_interval_ms: 5,
438            journal_ack_timeout_ms: 2000,
439            ..DurableConfig::default()
440        };
441
442        let local = Arc::new(LocalBackend::open(":memory:", 1_048_576).await.unwrap());
443        local.init().await.unwrap();
444        local
445            .open_execution(exec_id, ExecutionKind::AgentTurn)
446            .await
447            .unwrap();
448
449        let (writer, handle) = JournalWriter::new(local.clone(), &config);
450        let _writer_task = tokio::spawn(writer.run());
451        let backend = Arc::new(DurableBackendEnum::Local(local.clone()));
452
453        // "Run 1": fresh promise created; the child never resolves it (still running/lost).
454        let ctx1 = DurableContext::new(
455            exec_id,
456            ExecutionKind::AgentTurn,
457            false,
458            backend.clone(),
459            handle.clone(),
460            &config,
461        );
462        let (_promise1, seat_opt) = make_durable_promise(&ctx1).await.unwrap();
463        assert!(seat_opt.is_some());
464
465        // "Run 2": resumed context re-derives the same pending promise.
466        let ctx2 = DurableContext::new(
467            exec_id,
468            ExecutionKind::AgentTurn,
469            true,
470            backend,
471            handle,
472            &config,
473        );
474        let (promise2, seat_opt2) = make_durable_promise(&ctx2).await.unwrap();
475        assert!(promise2.is_resumed());
476        assert!(seat_opt2.is_none());
477
478        let replayed = try_replay_durable_subagent(&ctx2, &promise2).await.unwrap();
479        assert!(
480            replayed.is_none(),
481            "still-pending resumed promise must return None without parking"
482        );
483    }
484}