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}