Skip to main content

greentic_aw_runtime/graph/
executor.rs

1//! Durable graph executor — the node-visiting drive loop.
2//!
3//! Ports the semantics of the greentic-designer spike
4//! (`src/orchestrate/agent_graph/executor.rs`, origin/spike/agent-graph-engine-slice)
5//! with the following adaptations:
6//!
7//! - Uses [`GraphConfig`] / [`GraphRunState`] / [`CheckpointStore`] from this
8//!   crate rather than the designer's SQLite-backed checkpoint module.
9//! - Effect closures are `Arc<dyn Fn(…) -> BoxFut<…>>` (shared, cloneable)
10//!   instead of owned `Box<dyn Fn…>`.
11//! - `start`/`resume` return `Result<GraphRunOutcome, GraphExecError>` (not
12//!   `Result<()>`), carrying the final reply and visit trail.
13//!
14//! ## Record-then-checkpoint ordering (replayable resume)
15//!
16//! Each side-effect node (Agent, Tool) follows a strict two-write ordering:
17//! the effect's result is recorded into the node-visit store
18//! (`CheckpointStore::record_node_visit`) *immediately after the effect
19//! returns and before the checkpoint update*. The checkpoint update then
20//! commits the new cursor, state, and per-node `visits` counts atomically.
21//!
22//! On resume at cursor node N, the next attempt is `visits[N] + 1`. If a
23//! `(run, N, attempt)` visit row already exists, the effect ran but the
24//! process crashed before the checkpoint committed — so the recorded result
25//! is **replayed** instead of re-invoking the effect.
26
27use std::collections::HashMap;
28use std::future::Future;
29use std::pin::Pin;
30use std::sync::Arc;
31use std::sync::atomic::{AtomicU32, Ordering};
32
33use serde::{Deserialize, Serialize};
34use tokio::sync::Mutex as AsyncMutex;
35
36use crate::tenant::TenantContext;
37
38use super::checkpoint::{CheckpointError, CheckpointStore, GraphRunRecord, RunStatus};
39use super::model::{GraphConfig, GraphError, NodeKind};
40use super::router::route;
41use super::state::{GraphRole, GraphRunState};
42
43// ---------------------------------------------------------------------------
44// BoxFut alias — shared with checkpoint.rs pattern
45// ---------------------------------------------------------------------------
46
47/// Owned heap-allocated future, `Send + 'static`.
48///
49/// Kept `pub` (not `pub(crate)`) because external crates that construct
50/// [`AgentTurnFn`] or [`ToolFn`] closures must be able to name this type as
51/// their return type.  The Task-7 `DwAgentGraph` handler in `greentic-runner-host`
52/// is the primary consumer.
53pub type BoxFut<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
54
55// ---------------------------------------------------------------------------
56// Effect request / result types
57// ---------------------------------------------------------------------------
58
59/// Request payload delivered to an injected agent-turn closure.
60#[derive(Debug, Clone)]
61pub struct AgentTurnRequest {
62    /// Graph node id (for telemetry / routing context).
63    pub node_id: String,
64    /// System prompt from the node's configuration.
65    pub system_prompt: String,
66    /// Model identifier from the node's configuration.
67    pub model: String,
68    /// Current run state at the time of the turn.
69    pub state: GraphRunState,
70    /// LLM provider override from the node's `provider` field.
71    /// `None` when the field is absent (existing graphs) — the host maps
72    /// `None` to `"openai"` for backward compatibility.
73    pub provider: Option<String>,
74}
75
76/// Result returned by an injected agent-turn closure.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct AgentTurnResult {
79    /// The assistant's reply text.
80    pub reply: String,
81    /// `true` when the agent considers the issue fully resolved.
82    pub resolved: bool,
83}
84
85/// Request payload delivered to an injected tool closure.
86#[derive(Debug, Clone)]
87pub struct ToolCallRequest {
88    /// Graph node id.
89    pub node_id: String,
90    /// Tool name from the node's configuration.
91    pub tool_name: String,
92    /// Current run state at the time of the call.
93    pub state: GraphRunState,
94}
95
96// ---------------------------------------------------------------------------
97// Injected effect types
98// ---------------------------------------------------------------------------
99
100/// One agent turn: the host wires this to `AgentRuntime::step`.
101pub type AgentTurnFn = Arc<
102    dyn Fn(AgentTurnRequest) -> BoxFut<'static, Result<AgentTurnResult, GraphExecError>>
103        + Send
104        + Sync,
105>;
106
107/// One deterministic tool call.
108pub type ToolFn = Arc<
109    dyn Fn(ToolCallRequest) -> BoxFut<'static, Result<serde_json::Value, GraphExecError>>
110        + Send
111        + Sync,
112>;
113
114// ---------------------------------------------------------------------------
115// Supervisor effect types
116// ---------------------------------------------------------------------------
117
118/// Request payload delivered to an injected supervisor closure.
119#[derive(Debug, Clone)]
120pub struct SupervisorRequest {
121    /// Graph node id.
122    pub node_id: String,
123    /// System prompt from the supervisor node's configuration.
124    pub system_prompt: String,
125    /// Model identifier from the supervisor node's configuration.
126    pub model: String,
127    /// The declared routes for this supervisor node.
128    pub routes: Vec<crate::graph::model::SupervisorRoute>,
129    /// Current run state at the time of the routing decision.
130    pub state: GraphRunState,
131    /// LLM provider override from the node's `provider` field.
132    /// `None` when the field is absent (existing graphs) — the host maps
133    /// `None` to `"openai"` for backward compatibility.
134    pub provider: Option<String>,
135}
136
137/// Result returned by an injected supervisor closure.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct SupervisorResult {
140    /// The branch label chosen by the supervisor.
141    pub branch: String,
142    /// Raw reply text from the LLM (stored in the message log).
143    pub raw_reply: String,
144}
145
146/// One supervisor routing decision: the host wires this to `AgentRuntime::step`
147/// with a generated routing prompt containing the route menu.
148pub type SupervisorFn = Arc<
149    dyn Fn(SupervisorRequest) -> BoxFut<'static, Result<SupervisorResult, GraphExecError>>
150        + Send
151        + Sync,
152>;
153
154// ---------------------------------------------------------------------------
155// Approval effect types
156// ---------------------------------------------------------------------------
157
158/// Request payload delivered to an injected approval closure.
159#[derive(Debug, Clone)]
160pub struct ApprovalRequest {
161    /// Graph run id.
162    pub run_id: String,
163    /// Graph node id (the approval node).
164    pub node_id: String,
165    /// Tenant id the run belongs to.
166    pub tenant: String,
167    /// Human-readable title from the node's configuration.
168    pub title: String,
169    /// Gate mode: `"always"` | `"above_risk"` | `"above_confidence"`.
170    pub mode: String,
171    /// Risk threshold used when `mode == "above_risk"`.
172    pub risk_threshold: Option<f64>,
173    /// Confidence threshold used when `mode == "above_confidence"`.
174    pub confidence_threshold: Option<f64>,
175    /// Optional decision deadline, in milliseconds.
176    pub deadline_ms: Option<u64>,
177    /// Current run state at the time of the approval check.
178    pub state: GraphRunState,
179}
180
181/// Result returned by an injected approval closure.
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub enum ApprovalOutcome {
184    /// No decision has arrived yet — the executor parks the run
185    /// (`RunStatus::AwaitingInput`) at this node without advancing the
186    /// cursor or recording a node visit.
187    Awaiting,
188    /// A human decision has arrived; `branch` selects the outgoing edge the
189    /// executor advances along (e.g. `"approved"`, `"denied"`, `"timeout"`).
190    Decided { branch: String },
191}
192
193/// One approval-gate check: the host wires this to its approval-request
194/// transport (e.g. the `greentic.approval.request.v1` / `.response.v1`
195/// NATS subjects) and reports whether a decision has arrived yet.
196pub type ApprovalFn = Arc<
197    dyn Fn(ApprovalRequest) -> BoxFut<'static, Result<ApprovalOutcome, GraphExecError>>
198        + Send
199        + Sync,
200>;
201
202// ---------------------------------------------------------------------------
203// GraphExecError
204// ---------------------------------------------------------------------------
205
206/// Errors that may surface from [`GraphExecutor::start`] or
207/// [`GraphExecutor::resume`].
208#[derive(Debug, thiserror::Error)]
209pub enum GraphExecError {
210    #[error("graph run {run_id} exceeded the node-visit cap")]
211    IterationCap { run_id: String },
212
213    #[error("unknown node `{0}` (cursor corrupt or graph changed)")]
214    UnknownNode(String),
215
216    #[error("unknown run `{0}`")]
217    UnknownRun(String),
218
219    #[error("run `{0}` already completed")]
220    AlreadyCompleted(String),
221
222    #[error(transparent)]
223    Graph(#[from] GraphError),
224
225    #[error(transparent)]
226    Checkpoint(#[from] CheckpointError),
227
228    #[error("agent turn failed: {0}")]
229    AgentTurn(String),
230
231    #[error("tool call failed: {0}")]
232    Tool(String),
233
234    #[error("supervisor routing failed: {0}")]
235    Supervisor(String),
236}
237
238// ---------------------------------------------------------------------------
239// GraphRunOutcome
240// ---------------------------------------------------------------------------
241
242/// The final result of a drive-loop execution.
243#[derive(Debug, Clone)]
244pub struct GraphRunOutcome {
245    /// Terminal status (`Succeeded` or `Failed`).
246    pub status: RunStatus,
247    /// Last assistant message emitted (what the Respond node returns), or an
248    /// empty string if the run never produced an assistant reply.
249    pub reply: String,
250    /// One JSON entry per node visit.
251    ///
252    /// **Drive-loop shape** (normal execution or active resume):
253    /// `{"node": id, "kind": "agent|tool|router|respond", "attempt": n, "replayed": bool}`.
254    ///
255    /// **Terminal-resume shape** (returned by [`GraphExecutor::resume`] when the run
256    /// is already in a terminal state — built by `rebuild_trail_from_state`):
257    /// `{"kind": "user|agent|tool", "content": "…"}`.  The node id and attempt
258    /// count are not available from the stored message log, so this shape is
259    /// intentionally narrower.  Callers that need both shapes must handle both.
260    pub trail: Vec<serde_json::Value>,
261}
262
263// ---------------------------------------------------------------------------
264// Constants
265// ---------------------------------------------------------------------------
266
267/// Hard upper bound on node visits per `drive` call, independent of the
268/// per-router `maxIterations` cap. Prevents infinite loops on malformed
269/// graphs.
270pub const MAX_NODE_VISITS: u32 = 64;
271
272// ---------------------------------------------------------------------------
273// BranchCursor — durable per-branch frontier slot
274// ---------------------------------------------------------------------------
275
276/// One branch's position inside an in-flight parallel region.
277///
278/// The frontier is `Vec<BranchCursor>` (one entry per branch, ordered by
279/// branch label lexicographically). It is serialised into
280/// [`GraphRunRecord::frontier_json`] after every per-node visit so a crash
281/// mid-parallel resumes each branch from its last good node.
282///
283/// - `cursor`: the node id this branch will visit next. When the branch
284///   reaches the join node it stops without executing the join and records
285///   the join id here with `parked = true`.
286/// - `state_json`: a serialised [`GraphRunState`] private to this branch
287///   (a clone of the trunk state taken at fan-out, advanced by this branch's
288///   visits only — branches never observe each other's messages mid-flight).
289/// - `parked`: `true` once the branch has reached the join.
290#[derive(Debug, Clone, Serialize, Deserialize)]
291struct BranchCursor {
292    branch: String,
293    cursor: String,
294    state_json: String,
295    parked: bool,
296}
297
298// ---------------------------------------------------------------------------
299// Concurrency & durability model for parallel regions
300// ---------------------------------------------------------------------------
301//
302// Branches execute concurrently via `futures::future::join_all` over a
303// `Vec<BoxFut>` (the spec's named primitive; `futures` is already a dep).
304// `join_all` needs no `spawn`, so each branch future may borrow `&self`,
305// `tenant`, `run_id`, and the shared `GraphConfig` — all futures settle
306// before the await returns, so nothing escapes.
307//
308// Two pieces of shared mutable state, each with a single race-free owner:
309//
310//  1. The visits map. Each branch owns a PRIVATE `HashMap<String, u32>`
311//     (cloned from the trunk's). Because validation guarantees branch paths
312//     are node-disjoint until the join, the per-branch maps have disjoint key
313//     sets and merge without conflict after all branches park. Attempt
314//     numbering (for ledger replay) is therefore correct without any locking.
315//
316//  2. The global visit cap. `MAX_NODE_VISITS` is GLOBAL across all branches
317//     plus the trunk. A shared `Arc<AtomicU32>`, seeded with the trunk's
318//     visit count at fan-out, is incremented (fetch_add) by every branch
319//     immediately BEFORE each node visit; if the post-increment total exceeds
320//     the cap the branch returns `IterationCap`. This enforces the sum bound
321//     without the branches needing to see each other's private maps.
322//
323//  3. The durable frontier. A single `tokio::Mutex<Vec<BranchCursor>>` is the
324//     canonical frontier. After each branch node visit, the branch locks the
325//     mutex, mutates ONLY its own slot (cursor + state_json + parked), builds
326//     the whole record, and `save`s it while still holding the lock. Because
327//     `save` overwrites the entire record, serialising every writer behind one
328//     mutex is what prevents a lost update: no two branches ever build a
329//     record from a stale frontier copy. Lock hold time spans the `save`
330//     await, but contention is low (N branches, one short critical section per
331//     node visit) and correctness beats throughput here. Each branch only ever
332//     writes its own slot, so the held-across-await design has no logical
333//     conflict — the mutex purely linearises the blob writes.
334
335/// Shared, lock-guarded checkpoint coordinator for a parallel region.
336///
337/// Holds the canonical frontier plus the immutable trunk fields needed to
338/// rebuild a full [`GraphRunRecord`]. Every branch checkpoints through
339/// [`FrontierCheckpoint::save_slot`], which is serialised by the inner mutex.
340struct FrontierCheckpoint {
341    run_id: String,
342    graph_json: String,
343    parallel_node: String,
344    /// Trunk state + trunk visits, frozen at fan-out (the trunk is parked).
345    trunk_state_json: String,
346    trunk_visits: HashMap<String, u32>,
347    inner: AsyncMutex<Vec<BranchCursor>>,
348    /// Accumulates every branch's private visit counts (disjoint keys by
349    /// validation) so the merge can fold them into the trunk visits map.
350    branch_visits: AsyncMutex<HashMap<String, u32>>,
351}
352
353// ---------------------------------------------------------------------------
354// GraphExecutor
355// ---------------------------------------------------------------------------
356
357/// Drives agent-graph runs to completion, persisting checkpoints after every
358/// node so that a killed process can resume mid-loop.
359pub struct GraphExecutor {
360    store: Arc<dyn CheckpointStore>,
361    agent_turn: AgentTurnFn,
362    tool: ToolFn,
363    supervisor: SupervisorFn,
364    approval: ApprovalFn,
365}
366
367impl GraphExecutor {
368    /// Construct a new executor.
369    pub fn new(
370        store: Arc<dyn CheckpointStore>,
371        agent_turn: AgentTurnFn,
372        tool: ToolFn,
373        supervisor: SupervisorFn,
374        approval: ApprovalFn,
375    ) -> Self {
376        Self {
377            store,
378            agent_turn,
379            tool,
380            supervisor,
381            approval,
382        }
383    }
384
385    /// Start a **new** run.
386    ///
387    /// Validates that `run_id` is fresh:
388    /// - If a record already exists with status `Running` → delegates to
389    ///   resume logic.
390    /// - If a record exists in a terminal state → returns
391    ///   [`GraphExecError::AlreadyCompleted`].
392    ///
393    /// Otherwise, seeds [`GraphRunState`] with the user message, snapshots the
394    /// graph JSON, saves the initial `Running` record, and drives the loop.
395    pub async fn start(
396        &self,
397        tenant: &TenantContext,
398        run_id: &str,
399        cfg: &GraphConfig,
400        user_text: &str,
401    ) -> Result<GraphRunOutcome, GraphExecError> {
402        // Check if a record already exists.
403        if let Some(existing) = self.store.load(tenant, run_id).await? {
404            return match existing.status {
405                // `AwaitingInput` means the run is parked at an approval
406                // node. Re-driving from the parked cursor is the correct
407                // resume path: the Approval arm re-asks the `ApprovalFn`
408                // closure, which re-parks (`Awaiting`) if still undecided or
409                // advances (`Decided`) once a decision has arrived — so
410                // `AwaitingInput` is handled identically to `Running` here.
411                RunStatus::Running | RunStatus::AwaitingInput => {
412                    // Resume the in-flight run.
413                    self.drive_from_record(tenant, run_id, existing).await
414                }
415                RunStatus::Succeeded | RunStatus::Failed => {
416                    Err(GraphExecError::AlreadyCompleted(run_id.to_owned()))
417                }
418            };
419        }
420
421        // Fresh run — seed state.
422        let mut state = GraphRunState::default();
423        state.push_message(GraphRole::User, user_text);
424
425        let graph_json = serde_json::to_string(cfg)
426            .map_err(|e| GraphExecError::Checkpoint(CheckpointError::Serde(e)))?;
427        let cursor = cfg.graph.entry.clone();
428        let visits: HashMap<String, u32> = HashMap::new();
429
430        let rec = build_record(
431            run_id,
432            &graph_json,
433            &cursor,
434            &state,
435            &visits,
436            RunStatus::Running,
437        )?;
438        self.store.save(tenant, &rec).await?;
439
440        self.drive(tenant, run_id, cfg.clone(), cursor, state, visits)
441            .await
442    }
443
444    /// Resume an **existing** run.
445    ///
446    /// - If the run does not exist → [`GraphExecError::UnknownRun`].
447    /// - If the run is already in a terminal state → return the stored outcome
448    ///   WITHOUT re-driving.
449    pub async fn resume(
450        &self,
451        tenant: &TenantContext,
452        run_id: &str,
453    ) -> Result<GraphRunOutcome, GraphExecError> {
454        let rec = self
455            .store
456            .load(tenant, run_id)
457            .await?
458            .ok_or_else(|| GraphExecError::UnknownRun(run_id.to_owned()))?;
459
460        match rec.status {
461            RunStatus::Succeeded | RunStatus::Failed => {
462                // Terminal — rebuild outcome from stored state without re-driving.
463                let state: GraphRunState =
464                    serde_json::from_str(&rec.state_json).map_err(CheckpointError::Serde)?;
465                let reply = last_assistant_message(&state);
466                let trail = rebuild_trail_from_state(&state);
467                Ok(GraphRunOutcome {
468                    status: rec.status,
469                    reply,
470                    trail,
471                })
472            }
473            // Same rationale as `start` above — re-drive from the parked
474            // cursor; the Approval arm re-asks `ApprovalFn` and either
475            // re-parks or advances based on the current answer.
476            RunStatus::Running | RunStatus::AwaitingInput => {
477                self.drive_from_record(tenant, run_id, rec).await
478            }
479        }
480    }
481
482    // ------------------------------------------------------------------
483    // Internal helpers
484    // ------------------------------------------------------------------
485
486    /// Deserialise a stored record and call [`drive`].
487    async fn drive_from_record(
488        &self,
489        tenant: &TenantContext,
490        run_id: &str,
491        rec: GraphRunRecord,
492    ) -> Result<GraphRunOutcome, GraphExecError> {
493        let cfg: GraphConfig = GraphConfig::from_json(&rec.graph_json)?;
494        let state: GraphRunState =
495            serde_json::from_str(&rec.state_json).map_err(CheckpointError::Serde)?;
496        let visits: HashMap<String, u32> =
497            serde_json::from_str(&rec.visits_json).map_err(CheckpointError::Serde)?;
498
499        // A persisted frontier means the run crashed mid-parallel. Reconstruct
500        // the branch cursors and re-drive only the non-parked branches; the
501        // ledger replays completed branch visits. After the merge, control
502        // returns to the trunk drive loop at the join's successor.
503        if let Some(frontier_json) = &rec.frontier_json {
504            let frontier: Vec<BranchCursor> =
505                serde_json::from_str(frontier_json).map_err(CheckpointError::Serde)?;
506            return self
507                .resume_parallel(tenant, run_id, &cfg, &rec.cursor, state, visits, frontier)
508                .await;
509        }
510
511        let cursor = rec.cursor.clone();
512        self.drive(tenant, run_id, cfg, cursor, state, visits).await
513    }
514
515    /// The core node-visiting loop. Persists a checkpoint after every node.
516    ///
517    /// Semantics are ported faithfully from the designer spike:
518    /// - Record-before-checkpoint ordering for side-effect nodes.
519    /// - `iterations` increments AFTER `visits.insert` and message push (same
520    ///   placement as the spike).
521    /// - Respond node: saves Succeeded, returns immediately.
522    /// - Cap exhausted: saves Failed, returns `Err(IterationCap)`.
523    /// - Effect error: does NOT mark the run Failed (it stays Running so
524    ///   `resume` can retry the failed node).
525    async fn drive(
526        &self,
527        tenant: &TenantContext,
528        run_id: &str,
529        cfg: GraphConfig,
530        mut cursor: String,
531        mut state: GraphRunState,
532        mut visits: HashMap<String, u32>,
533    ) -> Result<GraphRunOutcome, GraphExecError> {
534        let mut trail: Vec<serde_json::Value> = Vec::new();
535
536        for _ in 0..MAX_NODE_VISITS {
537            let node = cfg
538                .graph
539                .node(&cursor)
540                .ok_or_else(|| GraphExecError::UnknownNode(cursor.clone()))?
541                .clone();
542
543            match &node.kind {
544                NodeKind::Agent {
545                    system_prompt,
546                    model,
547                    provider,
548                    ..
549                } => {
550                    let attempt = *visits.get(&cursor).unwrap_or(&0) + 1;
551
552                    // Pre-clone so the closure can own the values it needs.
553                    let node_id_for_err = cursor.clone();
554                    let provider_clone = provider.clone();
555                    let (raw, replayed) = self
556                        .visit_effect(tenant, run_id, &cursor, attempt, || {
557                            let req = AgentTurnRequest {
558                                node_id: node_id_for_err.clone(),
559                                system_prompt: system_prompt.clone(),
560                                model: model.clone(),
561                                state: state.clone(),
562                                provider: provider_clone,
563                            };
564                            let fut = (self.agent_turn)(req);
565                            Box::pin(async move {
566                                let r = fut.await.map_err(|e| {
567                                    GraphExecError::AgentTurn(format!(
568                                        "node '{}' attempt {}: {}",
569                                        node_id_for_err, attempt, e
570                                    ))
571                                })?;
572                                serde_json::to_value(&r)
573                                    .map_err(CheckpointError::Serde)
574                                    .map_err(GraphExecError::Checkpoint)
575                            })
576                        })
577                        .await?;
578
579                    let result: AgentTurnResult =
580                        serde_json::from_value(raw).map_err(CheckpointError::Serde)?;
581
582                    trail.push(serde_json::json!({
583                        "node": cursor,
584                        "kind": "agent",
585                        "attempt": attempt,
586                        "replayed": replayed,
587                    }));
588
589                    // Update state — in the same order as the spike.
590                    visits.insert(cursor.clone(), attempt);
591                    state.iterations += 1;
592                    state.push_message(GraphRole::Assistant, &result.reply);
593                    if result.resolved {
594                        state.resolved = true;
595                    }
596
597                    // Advance cursor (single outgoing edge).
598                    cursor = next_linear(&cfg, &cursor)?;
599
600                    // Checkpoint: cursor already advanced; state reflects this visit.
601                    let rec = build_record(
602                        run_id,
603                        &serde_json::to_string(&cfg).map_err(CheckpointError::Serde)?,
604                        &cursor,
605                        &state,
606                        &visits,
607                        RunStatus::Running,
608                    )?;
609                    self.store.save(tenant, &rec).await?;
610                }
611
612                NodeKind::Tool { tool_name } => {
613                    let attempt = *visits.get(&cursor).unwrap_or(&0) + 1;
614
615                    // Pre-clone so the closure can own the values it needs.
616                    let node_id_for_err = cursor.clone();
617                    let (result, replayed) = self
618                        .visit_effect(tenant, run_id, &cursor, attempt, || {
619                            let req = ToolCallRequest {
620                                node_id: node_id_for_err.clone(),
621                                tool_name: tool_name.clone(),
622                                state: state.clone(),
623                            };
624                            let fut = (self.tool)(req);
625                            Box::pin(async move {
626                                fut.await.map_err(|e| {
627                                    GraphExecError::Tool(format!(
628                                        "node '{}' attempt {}: {}",
629                                        node_id_for_err, attempt, e
630                                    ))
631                                })
632                            })
633                        })
634                        .await?;
635
636                    trail.push(serde_json::json!({
637                        "node": cursor,
638                        "kind": "tool",
639                        "attempt": attempt,
640                        "replayed": replayed,
641                    }));
642
643                    visits.insert(cursor.clone(), attempt);
644                    state.push_message(GraphRole::Tool, result.to_string());
645
646                    cursor = next_linear(&cfg, &cursor)?;
647
648                    let rec = build_record(
649                        run_id,
650                        &serde_json::to_string(&cfg).map_err(CheckpointError::Serde)?,
651                        &cursor,
652                        &state,
653                        &visits,
654                        RunStatus::Running,
655                    )?;
656                    self.store.save(tenant, &rec).await?;
657                }
658
659                NodeKind::Router { .. } => {
660                    let attempt = *visits.get(&cursor).unwrap_or(&0) + 1;
661
662                    let next = route(&cfg.graph, &cursor, &state)?;
663
664                    trail.push(serde_json::json!({
665                        "node": cursor,
666                        "kind": "router",
667                        "attempt": attempt,
668                        "replayed": false,
669                    }));
670
671                    visits.insert(cursor.clone(), attempt);
672                    cursor = next;
673
674                    let rec = build_record(
675                        run_id,
676                        &serde_json::to_string(&cfg).map_err(CheckpointError::Serde)?,
677                        &cursor,
678                        &state,
679                        &visits,
680                        RunStatus::Running,
681                    )?;
682                    self.store.save(tenant, &rec).await?;
683                }
684
685                NodeKind::Respond => {
686                    let attempt = *visits.get(&cursor).unwrap_or(&0) + 1;
687
688                    trail.push(serde_json::json!({
689                        "node": cursor,
690                        "kind": "respond",
691                        "attempt": attempt,
692                        "replayed": false,
693                    }));
694
695                    visits.insert(cursor.clone(), attempt);
696
697                    // Save terminal checkpoint.
698                    let rec = build_record(
699                        run_id,
700                        &serde_json::to_string(&cfg).map_err(CheckpointError::Serde)?,
701                        &cursor,
702                        &state,
703                        &visits,
704                        RunStatus::Succeeded,
705                    )?;
706                    self.store.save(tenant, &rec).await?;
707
708                    let reply = last_assistant_message(&state);
709                    return Ok(GraphRunOutcome {
710                        status: RunStatus::Succeeded,
711                        reply,
712                        trail,
713                    });
714                }
715
716                NodeKind::Supervisor {
717                    system_prompt,
718                    model,
719                    routes,
720                    provider,
721                } => {
722                    let attempt = *visits.get(&cursor).unwrap_or(&0) + 1;
723                    let node_id_for_err = cursor.clone();
724                    let routes_clone = routes.clone();
725                    let system_prompt_clone = system_prompt.clone();
726                    let model_clone = model.clone();
727                    let provider_clone = provider.clone();
728
729                    let (raw, replayed) = self
730                        .visit_effect(tenant, run_id, &cursor, attempt, || {
731                            let req = SupervisorRequest {
732                                node_id: node_id_for_err.clone(),
733                                system_prompt: system_prompt_clone,
734                                model: model_clone,
735                                routes: routes_clone.clone(),
736                                state: state.clone(),
737                                provider: provider_clone,
738                            };
739                            let fut = (self.supervisor)(req);
740                            Box::pin(async move {
741                                let r = fut.await.map_err(|e| {
742                                    GraphExecError::Supervisor(format!(
743                                        "node '{}' attempt {}: {}",
744                                        node_id_for_err, attempt, e
745                                    ))
746                                })?;
747                                serde_json::to_value(&r)
748                                    .map_err(CheckpointError::Serde)
749                                    .map_err(GraphExecError::Checkpoint)
750                            })
751                        })
752                        .await?;
753
754                    let result: SupervisorResult =
755                        serde_json::from_value(raw).map_err(CheckpointError::Serde)?;
756
757                    // Validate that the chosen branch is one of the declared routes
758                    // AND has a matching outgoing edge.
759                    let branch = &result.branch;
760                    let branch_is_valid_route = routes.iter().any(|r| &r.branch == branch);
761                    let matching_edge = cfg
762                        .graph
763                        .edges_from(&cursor)
764                        .find(|e| e.branch.as_deref() == Some(branch.as_str()));
765
766                    let next_cursor = match (branch_is_valid_route, matching_edge) {
767                        (true, Some(edge)) => edge.to.clone(),
768                        _ => {
769                            return Err(GraphExecError::Graph(super::model::GraphError::Invalid(
770                                format!(
771                                    "supervisor node '{}': branch '{}' returned by supervisor \
772                                     does not match any declared route or outgoing edge",
773                                    cursor, branch
774                                ),
775                            )));
776                        }
777                    };
778
779                    trail.push(serde_json::json!({
780                        "node": cursor,
781                        "kind": "supervisor",
782                        "attempt": attempt,
783                        "replayed": replayed,
784                        "branch": result.branch,
785                    }));
786
787                    visits.insert(cursor.clone(), attempt);
788                    state.push_message(GraphRole::Assistant, &result.raw_reply);
789
790                    // Supervisor does NOT increment iterations.
791                    cursor = next_cursor;
792
793                    let rec = build_record(
794                        run_id,
795                        &serde_json::to_string(&cfg).map_err(CheckpointError::Serde)?,
796                        &cursor,
797                        &state,
798                        &visits,
799                        RunStatus::Running,
800                    )?;
801                    self.store.save(tenant, &rec).await?;
802                }
803
804                // v2 Parallel — fan out into a concurrent frontier, drive every
805                // branch to the join, deterministically merge, and resume the
806                // trunk at the join's successor. The whole region runs inside
807                // `run_parallel_region`, which advances `cursor`, `state`, and
808                // `visits` past the join.
809                NodeKind::Parallel => {
810                    let (next_cursor, merged_state, merged_visits, region_trail) = self
811                        .run_parallel_region(tenant, run_id, &cfg, &cursor, &state, &visits)
812                        .await?;
813                    trail.extend(region_trail);
814                    cursor = next_cursor;
815                    state = merged_state;
816                    visits = merged_visits;
817                    // Continue the trunk loop from the join's successor.
818                }
819
820                // v2 Join — the join node itself is consumed by the parallel
821                // arm (the trunk resumes at the join's OUTGOING target after the
822                // merge), so the single-cursor loop should never land here. If a
823                // malformed graph routes here directly, treat it as a defensive
824                // pass-through: advance to its single outgoing edge. Validation
825                // guarantees a join has exactly one outgoing edge.
826                NodeKind::Join => {
827                    tracing::warn!(
828                        node = %cursor,
829                        "trunk drive loop reached a join node directly; \
830                         passing through to its successor (expected to be \
831                         consumed by the parallel arm)"
832                    );
833                    cursor = next_linear(&cfg, &cursor)?;
834                }
835
836                // Human-in-the-loop approval gate. Ask the host's `ApprovalFn`
837                // whether a decision has arrived yet:
838                // - `Awaiting`: park the run (`RunStatus::AwaitingInput`) with
839                //   the cursor still AT this node (not advanced) and return
840                //   the outcome cleanly, without recording a node visit — the
841                //   next `drive` pass (via `resume`) re-asks the same
842                //   question.
843                // - `Decided { branch }`: select the outgoing edge whose
844                //   label matches `branch`, record the decision as this
845                //   node's visit, and advance the loop.
846                NodeKind::Approval {
847                    title,
848                    mode,
849                    risk_threshold,
850                    confidence_threshold,
851                    deadline_ms,
852                } => {
853                    let req = ApprovalRequest {
854                        run_id: run_id.to_string(),
855                        node_id: cursor.clone(),
856                        tenant: tenant.tenant_id.clone(),
857                        title: title.clone(),
858                        mode: mode.clone(),
859                        risk_threshold: *risk_threshold,
860                        confidence_threshold: *confidence_threshold,
861                        deadline_ms: *deadline_ms,
862                        state: state.clone(),
863                    };
864
865                    match (self.approval)(req).await? {
866                        ApprovalOutcome::Awaiting => {
867                            // Park: persist AwaitingInput with cursor still at
868                            // THIS node (not advanced). Do NOT record a node
869                            // visit for the park itself — only a `Decided`
870                            // outcome produces a ledgered visit.
871                            let rec = build_record(
872                                run_id,
873                                &serde_json::to_string(&cfg).map_err(CheckpointError::Serde)?,
874                                &cursor,
875                                &state,
876                                &visits,
877                                RunStatus::AwaitingInput,
878                            )?;
879                            self.store.save(tenant, &rec).await?;
880
881                            let reply = last_assistant_message(&state);
882                            return Ok(GraphRunOutcome {
883                                status: RunStatus::AwaitingInput,
884                                reply,
885                                trail,
886                            });
887                        }
888                        ApprovalOutcome::Decided { branch } => {
889                            // Select the outgoing edge whose label matches the
890                            // decided branch — same lookup idiom the
891                            // Supervisor arm uses for its branch label.
892                            let matching_edge = cfg
893                                .graph
894                                .edges_from(&cursor)
895                                .find(|e| e.branch.as_deref() == Some(branch.as_str()));
896                            let next_cursor = match matching_edge {
897                                Some(edge) => edge.to.clone(),
898                                None => {
899                                    return Err(GraphExecError::Graph(GraphError::Invalid(
900                                        format!(
901                                            "approval node '{}': decision branch '{}' does not \
902                                             match any outgoing edge",
903                                            cursor, branch
904                                        ),
905                                    )));
906                                }
907                            };
908
909                            let attempt = *visits.get(&cursor).unwrap_or(&0) + 1;
910                            self.store
911                                .record_node_visit(
912                                    tenant,
913                                    run_id,
914                                    &cursor,
915                                    attempt,
916                                    &serde_json::json!({"decision": branch}),
917                                )
918                                .await?;
919
920                            trail.push(serde_json::json!({
921                                "node": cursor,
922                                "kind": "approval",
923                                "attempt": attempt,
924                                "replayed": false,
925                                "branch": branch,
926                            }));
927
928                            visits.insert(cursor.clone(), attempt);
929                            cursor = next_cursor;
930
931                            let rec = build_record(
932                                run_id,
933                                &serde_json::to_string(&cfg).map_err(CheckpointError::Serde)?,
934                                &cursor,
935                                &state,
936                                &visits,
937                                RunStatus::Running,
938                            )?;
939                            self.store.save(tenant, &rec).await?;
940                        }
941                    }
942                }
943            }
944        }
945
946        // Cap exhausted — mark as Failed first, then propagate the error.
947        let graph_json = serde_json::to_string(&cfg).map_err(CheckpointError::Serde)?;
948        let rec = build_record(
949            run_id,
950            &graph_json,
951            &cursor,
952            &state,
953            &visits,
954            RunStatus::Failed,
955        )?;
956        self.store.save(tenant, &rec).await?;
957
958        Err(GraphExecError::IterationCap {
959            run_id: run_id.to_owned(),
960        })
961    }
962
963    /// Load-or-invoke-and-record a single side-effect node visit.
964    ///
965    /// Returns `(serialised_result, replayed)`:
966    /// - `replayed = true` means the result was already stored (crash recovery);
967    ///   the `invoke` closure was NOT called.
968    /// - `replayed = false` means `invoke` was called and its result has been
969    ///   durably recorded before returning.
970    ///
971    /// The `invoke` closure is responsible for wrapping any effect-level error
972    /// with `node_id`/`attempt` context **before** returning it here, so that
973    /// `?`-propagation in the caller carries the full diagnostic.
974    async fn visit_effect(
975        &self,
976        tenant: &TenantContext,
977        run_id: &str,
978        node_id: &str,
979        attempt: u32,
980        invoke: impl FnOnce() -> BoxFut<'static, Result<serde_json::Value, GraphExecError>>,
981    ) -> Result<(serde_json::Value, bool), GraphExecError> {
982        if let Some(cached) = self
983            .store
984            .load_node_visit(tenant, run_id, node_id, attempt)
985            .await?
986        {
987            return Ok((cached, true));
988        }
989
990        // Effect not yet recorded — invoke and record before returning.
991        let value = invoke().await?;
992        // Record BEFORE checkpoint (replayable resume ordering).
993        self.store
994            .record_node_visit(tenant, run_id, node_id, attempt, &value)
995            .await?;
996        Ok((value, false))
997    }
998
999    // ------------------------------------------------------------------
1000    // Parallel region driver
1001    // ------------------------------------------------------------------
1002
1003    /// Fan out a [`NodeKind::Parallel`] node into concurrent branch drives,
1004    /// merge deterministically at the join, and return the trunk continuation.
1005    ///
1006    /// Returns `(next_cursor, merged_state, merged_visits, trail)` where
1007    /// `next_cursor` is the join's single outgoing target.
1008    #[allow(clippy::type_complexity)]
1009    async fn run_parallel_region(
1010        &self,
1011        tenant: &TenantContext,
1012        run_id: &str,
1013        cfg: &GraphConfig,
1014        parallel_node: &str,
1015        trunk_state: &GraphRunState,
1016        trunk_visits: &HashMap<String, u32>,
1017    ) -> Result<
1018        (
1019            String,
1020            GraphRunState,
1021            HashMap<String, u32>,
1022            Vec<serde_json::Value>,
1023        ),
1024        GraphExecError,
1025    > {
1026        // Enumerate branch edges sorted by label (deterministic ordering).
1027        let mut branch_edges: Vec<(String, String)> = cfg
1028            .graph
1029            .edges_from(parallel_node)
1030            .filter_map(|e| e.branch.clone().map(|b| (b, e.to.clone())))
1031            .collect();
1032        branch_edges.sort_by(|a, b| a.0.cmp(&b.0));
1033
1034        // Resolve the single join node for this region.
1035        let join_id = find_join_for_parallel(cfg, parallel_node)?;
1036
1037        // Snapshot the trunk state once per branch (isolated clones).
1038        let trunk_state_json =
1039            serde_json::to_string(trunk_state).map_err(CheckpointError::Serde)?;
1040        let frontier: Vec<BranchCursor> = branch_edges
1041            .iter()
1042            .map(|(branch, target)| BranchCursor {
1043                branch: branch.clone(),
1044                cursor: target.clone(),
1045                state_json: trunk_state_json.clone(),
1046                parked: false,
1047            })
1048            .collect();
1049
1050        self.drive_frontier(
1051            tenant,
1052            run_id,
1053            cfg,
1054            parallel_node,
1055            &join_id,
1056            trunk_state,
1057            trunk_visits,
1058            frontier,
1059            /* persist_before_driving = */ true,
1060        )
1061        .await
1062    }
1063
1064    /// Resume an in-flight parallel region from a reconstructed frontier.
1065    ///
1066    /// Parked branches are already done; non-parked branches re-drive from
1067    /// their last good cursor, replaying ledgered visits.
1068    #[allow(clippy::too_many_arguments, clippy::type_complexity)]
1069    async fn resume_parallel(
1070        &self,
1071        tenant: &TenantContext,
1072        run_id: &str,
1073        cfg: &GraphConfig,
1074        parallel_node: &str,
1075        trunk_state: GraphRunState,
1076        trunk_visits: HashMap<String, u32>,
1077        frontier: Vec<BranchCursor>,
1078    ) -> Result<GraphRunOutcome, GraphExecError> {
1079        let join_id = find_join_for_parallel(cfg, parallel_node)?;
1080
1081        let (next_cursor, merged_state, merged_visits, _region_trail) = self
1082            .drive_frontier(
1083                tenant,
1084                run_id,
1085                cfg,
1086                parallel_node,
1087                &join_id,
1088                &trunk_state,
1089                &trunk_visits,
1090                frontier,
1091                /* persist_before_driving = */ false,
1092            )
1093            .await?;
1094
1095        // The merge is committed; continue the trunk drive loop from the
1096        // join's successor.
1097        self.drive(
1098            tenant,
1099            run_id,
1100            cfg.clone(),
1101            next_cursor,
1102            merged_state,
1103            merged_visits,
1104        )
1105        .await
1106    }
1107
1108    /// Drive the supplied `frontier` of branches concurrently to the join, then
1109    /// deterministically merge. Shared between fresh fan-out and resume.
1110    ///
1111    /// `persist_before_driving` checkpoints the initial frontier before driving
1112    /// (fresh fan-out path); on resume the frontier is already durable.
1113    #[allow(clippy::too_many_arguments, clippy::type_complexity)]
1114    async fn drive_frontier(
1115        &self,
1116        tenant: &TenantContext,
1117        run_id: &str,
1118        cfg: &GraphConfig,
1119        parallel_node: &str,
1120        join_id: &str,
1121        trunk_state: &GraphRunState,
1122        trunk_visits: &HashMap<String, u32>,
1123        frontier: Vec<BranchCursor>,
1124        persist_before_driving: bool,
1125    ) -> Result<
1126        (
1127            String,
1128            GraphRunState,
1129            HashMap<String, u32>,
1130            Vec<serde_json::Value>,
1131        ),
1132        GraphExecError,
1133    > {
1134        let graph_json = serde_json::to_string(cfg).map_err(CheckpointError::Serde)?;
1135        let trunk_state_json =
1136            serde_json::to_string(trunk_state).map_err(CheckpointError::Serde)?;
1137
1138        let coord = Arc::new(FrontierCheckpoint {
1139            run_id: run_id.to_owned(),
1140            graph_json: graph_json.clone(),
1141            parallel_node: parallel_node.to_owned(),
1142            trunk_state_json,
1143            trunk_visits: trunk_visits.clone(),
1144            inner: AsyncMutex::new(frontier.clone()),
1145            branch_visits: AsyncMutex::new(HashMap::new()),
1146        });
1147
1148        // Persist the initial frontier BEFORE driving (fresh fan-out only).
1149        if persist_before_driving {
1150            coord.checkpoint(self.store.as_ref(), tenant).await?;
1151        }
1152
1153        // Global visit counter, seeded with the trunk's current visit total.
1154        let trunk_visit_total: u32 = trunk_visits.values().copied().sum();
1155        let global_visits = Arc::new(AtomicU32::new(trunk_visit_total));
1156
1157        // Build one future per branch (skipping already-parked branches).
1158        let mut futs: Vec<BoxFut<'_, Result<BranchOutcome, GraphExecError>>> = Vec::new();
1159        for (slot, bc) in frontier.iter().enumerate() {
1160            if bc.parked {
1161                continue;
1162            }
1163            let bc = bc.clone();
1164            let coord = coord.clone();
1165            let global_visits = global_visits.clone();
1166            futs.push(Box::pin(self.drive_branch(
1167                tenant,
1168                run_id,
1169                cfg,
1170                join_id,
1171                slot,
1172                bc,
1173                trunk_visits.clone(),
1174                coord,
1175                global_visits,
1176            )));
1177        }
1178
1179        // Settle ALL branch futures, THEN inspect results. We never cancel an
1180        // in-flight branch: cancelling mid-effect could drop an effect after it
1181        // ran but before it was recorded, breaking crash-replay. So we let every
1182        // branch run its current await to completion (each branch's per-node
1183        // checkpoint persists its last good cursor), collect outcomes, and only
1184        // afterwards propagate the FIRST error. This "settle then propagate"
1185        // policy guarantees every completed visit is durably recorded; the run
1186        // stays Running so resume re-drives just the non-parked branches.
1187        let results = futures::future::join_all(futs).await;
1188
1189        let mut first_err: Option<GraphExecError> = None;
1190        let mut trail: Vec<serde_json::Value> = Vec::new();
1191        let mut merged_visits = trunk_visits.clone();
1192        for r in results {
1193            match r {
1194                Ok(outcome) => {
1195                    trail.extend(outcome.trail);
1196                    // Disjoint keys by validation — no conflict on insert.
1197                    for (k, v) in outcome.visits {
1198                        merged_visits.insert(k, v);
1199                    }
1200                }
1201                Err(e) if first_err.is_none() => first_err = Some(e),
1202                Err(_) => { /* keep only the first error; others already settled */ }
1203            }
1204        }
1205        if let Some(e) = first_err {
1206            // A global visit-cap breach is terminal: write Failed exactly once,
1207            // now that every branch has settled, so no concurrent slot save can
1208            // clobber the terminal status. Effect errors (agent/tool/supervisor)
1209            // are NOT terminal — leave the run Running (the last good frontier is
1210            // already persisted) so resume re-drives only the non-parked branches.
1211            if matches!(e, GraphExecError::IterationCap { .. }) {
1212                let last_frontier = coord.inner.lock().await.clone();
1213                let frontier_json =
1214                    Some(serde_json::to_string(&last_frontier).map_err(CheckpointError::Serde)?);
1215                let rec = build_record_with_frontier(
1216                    run_id,
1217                    &graph_json,
1218                    parallel_node,
1219                    trunk_state,
1220                    trunk_visits,
1221                    RunStatus::Failed,
1222                    frontier_json,
1223                )?;
1224                self.store.save(tenant, &rec).await?;
1225            }
1226            return Err(e);
1227        }
1228
1229        // All branches parked → deterministic merge in branch-label order.
1230        // Re-read the durable frontier (covers branches whose work was wholly
1231        // replayed on resume and thus produced no fresh BranchOutcome visits).
1232        let mut ordered = coord.inner.lock().await.clone();
1233        ordered.sort_by(|a, b| a.branch.cmp(&b.branch));
1234
1235        let snapshot_len = trunk_state.messages.len();
1236        let mut merged_state = trunk_state.clone();
1237
1238        for bc in &ordered {
1239            let branch_state: GraphRunState =
1240                serde_json::from_str(&bc.state_json).map_err(CheckpointError::Serde)?;
1241
1242            // Append the branch's NEW messages (delta after the snapshot).
1243            for msg in branch_state.messages.iter().skip(snapshot_len) {
1244                merged_state.messages.push(msg.clone());
1245            }
1246            // resolved = any(branch.resolved).
1247            if branch_state.resolved {
1248                merged_state.resolved = true;
1249            }
1250            // iterations = max across branches and the trunk.
1251            merged_state.iterations = merged_state.iterations.max(branch_state.iterations);
1252            // scratchpad.branches.<label> = branch scratchpad.
1253            if !branch_state.scratchpad.is_null() {
1254                if !merged_state.scratchpad.is_object() {
1255                    merged_state.scratchpad = serde_json::json!({});
1256                }
1257                if let Some(obj) = merged_state.scratchpad.as_object_mut() {
1258                    let branches = obj
1259                        .entry("branches")
1260                        .or_insert_with(|| serde_json::json!({}));
1261                    if let Some(branches_obj) = branches.as_object_mut() {
1262                        branches_obj.insert(bc.branch.clone(), branch_state.scratchpad.clone());
1263                    }
1264                }
1265            }
1266        }
1267
1268        // Also fold in any visit counts the coordinator accumulated (covers the
1269        // resume case where a branch parked via pure replay).
1270        for (k, v) in coord.collect_branch_visits().await {
1271            merged_visits.entry(k).or_insert(v);
1272        }
1273
1274        // The join's single outgoing target is the trunk continuation.
1275        let next_cursor = next_linear(cfg, join_id)?;
1276
1277        // Clear the frontier and checkpoint the merged trunk as Running before
1278        // returning control to the trunk loop.
1279        let rec = build_record(
1280            run_id,
1281            &graph_json,
1282            &next_cursor,
1283            &merged_state,
1284            &merged_visits,
1285            RunStatus::Running,
1286        )?;
1287        self.store.save(tenant, &rec).await?;
1288
1289        Ok((next_cursor, merged_state, merged_visits, trail))
1290    }
1291
1292    /// Drive ONE branch from its cursor to the join (exclusive). Mirrors the
1293    /// trunk node-execution logic (agent/tool/router/supervisor via
1294    /// `visit_effect`) but parks instead of executing the join.
1295    #[allow(clippy::too_many_arguments)]
1296    fn drive_branch<'a>(
1297        &'a self,
1298        tenant: &'a TenantContext,
1299        run_id: &'a str,
1300        cfg: &'a GraphConfig,
1301        join_id: &'a str,
1302        slot: usize,
1303        mut bc: BranchCursor,
1304        mut visits: HashMap<String, u32>,
1305        coord: Arc<FrontierCheckpoint>,
1306        global_visits: Arc<AtomicU32>,
1307    ) -> BoxFut<'a, Result<BranchOutcome, GraphExecError>> {
1308        Box::pin(async move {
1309            let mut state: GraphRunState =
1310                serde_json::from_str(&bc.state_json).map_err(CheckpointError::Serde)?;
1311            let mut trail: Vec<serde_json::Value> = Vec::new();
1312            let branch_label = bc.branch.clone();
1313
1314            loop {
1315                // Reached the join → park without executing it.
1316                if bc.cursor == join_id {
1317                    bc.parked = true;
1318                    bc.state_json =
1319                        serde_json::to_string(&state).map_err(CheckpointError::Serde)?;
1320                    coord
1321                        .save_slot(self.store.as_ref(), tenant, slot, &bc)
1322                        .await?;
1323                    return Ok(BranchOutcome {
1324                        branch: branch_label,
1325                        visits,
1326                        trail,
1327                    });
1328                }
1329
1330                // Global visit cap — count this visit BEFORE executing.
1331                // Return the error WITHOUT writing a terminal record here: with
1332                // concurrent branches, a Failed write from one branch could be
1333                // clobbered by another branch's later Running slot save. The
1334                // caller (`drive_frontier`) writes Failed exactly once after all
1335                // branches have settled, so the terminal status can't be lost.
1336                let total = global_visits.fetch_add(1, Ordering::SeqCst) + 1;
1337                if total > MAX_NODE_VISITS {
1338                    return Err(GraphExecError::IterationCap {
1339                        run_id: run_id.to_owned(),
1340                    });
1341                }
1342
1343                let node = cfg
1344                    .graph
1345                    .node(&bc.cursor)
1346                    .ok_or_else(|| GraphExecError::UnknownNode(bc.cursor.clone()))?
1347                    .clone();
1348
1349                match &node.kind {
1350                    NodeKind::Agent {
1351                        system_prompt,
1352                        model,
1353                        provider,
1354                        ..
1355                    } => {
1356                        let attempt = *visits.get(&bc.cursor).unwrap_or(&0) + 1;
1357                        let node_id = bc.cursor.clone();
1358                        let sp = system_prompt.clone();
1359                        let md = model.clone();
1360                        let pv = provider.clone();
1361                        let state_for_call = state.clone();
1362                        let (raw, replayed) = self
1363                            .visit_effect(tenant, run_id, &bc.cursor, attempt, || {
1364                                let req = AgentTurnRequest {
1365                                    node_id: node_id.clone(),
1366                                    system_prompt: sp,
1367                                    model: md,
1368                                    state: state_for_call,
1369                                    provider: pv,
1370                                };
1371                                let fut = (self.agent_turn)(req);
1372                                Box::pin(async move {
1373                                    let r = fut.await.map_err(|e| {
1374                                        GraphExecError::AgentTurn(format!(
1375                                            "node '{}' attempt {}: {}",
1376                                            node_id, attempt, e
1377                                        ))
1378                                    })?;
1379                                    serde_json::to_value(&r)
1380                                        .map_err(CheckpointError::Serde)
1381                                        .map_err(GraphExecError::Checkpoint)
1382                                })
1383                            })
1384                            .await?;
1385                        let result: AgentTurnResult =
1386                            serde_json::from_value(raw).map_err(CheckpointError::Serde)?;
1387                        trail.push(serde_json::json!({
1388                            "node": bc.cursor, "kind": "agent",
1389                            "attempt": attempt, "replayed": replayed, "branch": branch_label,
1390                        }));
1391                        visits.insert(bc.cursor.clone(), attempt);
1392                        state.iterations += 1;
1393                        state.push_message(GraphRole::Assistant, &result.reply);
1394                        if result.resolved {
1395                            state.resolved = true;
1396                        }
1397                        bc.cursor = next_linear(cfg, &bc.cursor)?;
1398                    }
1399                    NodeKind::Tool { tool_name } => {
1400                        let attempt = *visits.get(&bc.cursor).unwrap_or(&0) + 1;
1401                        let node_id = bc.cursor.clone();
1402                        let tn = tool_name.clone();
1403                        let state_for_call = state.clone();
1404                        let (result, replayed) = self
1405                            .visit_effect(tenant, run_id, &bc.cursor, attempt, || {
1406                                let req = ToolCallRequest {
1407                                    node_id: node_id.clone(),
1408                                    tool_name: tn,
1409                                    state: state_for_call,
1410                                };
1411                                let fut = (self.tool)(req);
1412                                Box::pin(async move {
1413                                    fut.await.map_err(|e| {
1414                                        GraphExecError::Tool(format!(
1415                                            "node '{}' attempt {}: {}",
1416                                            node_id, attempt, e
1417                                        ))
1418                                    })
1419                                })
1420                            })
1421                            .await?;
1422                        trail.push(serde_json::json!({
1423                            "node": bc.cursor, "kind": "tool",
1424                            "attempt": attempt, "replayed": replayed, "branch": branch_label,
1425                        }));
1426                        visits.insert(bc.cursor.clone(), attempt);
1427                        state.push_message(GraphRole::Tool, result.to_string());
1428                        bc.cursor = next_linear(cfg, &bc.cursor)?;
1429                    }
1430                    NodeKind::Router { .. } => {
1431                        let attempt = *visits.get(&bc.cursor).unwrap_or(&0) + 1;
1432                        let next = route(&cfg.graph, &bc.cursor, &state)?;
1433                        trail.push(serde_json::json!({
1434                            "node": bc.cursor, "kind": "router",
1435                            "attempt": attempt, "replayed": false, "branch": branch_label,
1436                        }));
1437                        visits.insert(bc.cursor.clone(), attempt);
1438                        bc.cursor = next;
1439                    }
1440                    NodeKind::Supervisor {
1441                        system_prompt,
1442                        model,
1443                        routes,
1444                        provider,
1445                    } => {
1446                        let attempt = *visits.get(&bc.cursor).unwrap_or(&0) + 1;
1447                        let node_id = bc.cursor.clone();
1448                        let sp = system_prompt.clone();
1449                        let md = model.clone();
1450                        let pv = provider.clone();
1451                        let routes_clone = routes.clone();
1452                        let state_for_call = state.clone();
1453                        let (raw, replayed) = self
1454                            .visit_effect(tenant, run_id, &bc.cursor, attempt, || {
1455                                let req = SupervisorRequest {
1456                                    node_id: node_id.clone(),
1457                                    system_prompt: sp,
1458                                    model: md,
1459                                    routes: routes_clone.clone(),
1460                                    state: state_for_call,
1461                                    provider: pv,
1462                                };
1463                                let fut = (self.supervisor)(req);
1464                                Box::pin(async move {
1465                                    let r = fut.await.map_err(|e| {
1466                                        GraphExecError::Supervisor(format!(
1467                                            "node '{}' attempt {}: {}",
1468                                            node_id, attempt, e
1469                                        ))
1470                                    })?;
1471                                    serde_json::to_value(&r)
1472                                        .map_err(CheckpointError::Serde)
1473                                        .map_err(GraphExecError::Checkpoint)
1474                                })
1475                            })
1476                            .await?;
1477                        let result: SupervisorResult =
1478                            serde_json::from_value(raw).map_err(CheckpointError::Serde)?;
1479                        let branch = &result.branch;
1480                        let matching_edge = cfg
1481                            .graph
1482                            .edges_from(&bc.cursor)
1483                            .find(|e| e.branch.as_deref() == Some(branch.as_str()));
1484                        let next_cursor =
1485                            match (routes.iter().any(|r| &r.branch == branch), matching_edge) {
1486                                (true, Some(edge)) => edge.to.clone(),
1487                                _ => {
1488                                    return Err(GraphExecError::Graph(GraphError::Invalid(
1489                                        format!(
1490                                            "supervisor node '{}': branch '{}' does not match any \
1491                                     declared route or outgoing edge",
1492                                            bc.cursor, branch
1493                                        ),
1494                                    )));
1495                                }
1496                            };
1497                        trail.push(serde_json::json!({
1498                            "node": bc.cursor, "kind": "supervisor",
1499                            "attempt": attempt, "replayed": replayed,
1500                            "branch": result.branch, "branch_path": branch_label,
1501                        }));
1502                        visits.insert(bc.cursor.clone(), attempt);
1503                        state.push_message(GraphRole::Assistant, &result.raw_reply);
1504                        bc.cursor = next_cursor;
1505                    }
1506                    NodeKind::Respond => {
1507                        // Validation forbids respond inside a parallel branch.
1508                        return Err(GraphExecError::Graph(GraphError::Invalid(format!(
1509                            "respond node '{}' inside parallel branch '{}' is not allowed",
1510                            bc.cursor, branch_label
1511                        ))));
1512                    }
1513                    NodeKind::Parallel | NodeKind::Join => {
1514                        // Validation forbids nested parallel; a join other than
1515                        // the region's join is unreachable. Guard defensively.
1516                        return Err(GraphExecError::Graph(GraphError::Invalid(format!(
1517                            "branch '{}' reached unexpected '{}' node '{}'",
1518                            branch_label,
1519                            node.kind.kind_name(),
1520                            bc.cursor
1521                        ))));
1522                    }
1523                    // Approval nodes inside a parallel branch are UNSUPPORTED
1524                    // in v1: parking a single branch mid-fan-out would need
1525                    // per-branch `AwaitingInput` semantics (which branch is
1526                    // parked vs. running, how the frontier round-trips a
1527                    // decision back into ONE slot) that the durable-frontier
1528                    // design does not model yet. Rather than silently
1529                    // mis-executing an approval gate (e.g. skipping it, or
1530                    // parking the whole region), fail loudly so a malformed
1531                    // graph is caught at drive time instead of producing a
1532                    // wrong decision. Revisit if/when parallel-region
1533                    // approval becomes a real requirement.
1534                    NodeKind::Approval { .. } => {
1535                        return Err(GraphExecError::Graph(GraphError::Invalid(format!(
1536                            "approval node '{}' inside parallel branch '{}': not supported in v1 (parallel-branch approval parking is unimplemented)",
1537                            bc.cursor, branch_label
1538                        ))));
1539                    }
1540                }
1541
1542                // Durably persist this branch's progress after every node.
1543                bc.state_json = serde_json::to_string(&state).map_err(CheckpointError::Serde)?;
1544                coord
1545                    .save_slot(self.store.as_ref(), tenant, slot, &bc)
1546                    .await?;
1547                // Record this branch's visit counts in the coordinator so the
1548                // merge can fold them into the trunk visits map.
1549                coord.record_branch_visits(&visits).await;
1550            }
1551        })
1552    }
1553}
1554
1555// ---------------------------------------------------------------------------
1556// BranchOutcome — what a single branch drive returns
1557// ---------------------------------------------------------------------------
1558
1559/// Result of driving one branch to its join.
1560struct BranchOutcome {
1561    #[allow(dead_code)]
1562    branch: String,
1563    /// This branch's private visits map (disjoint keys; merged into trunk).
1564    visits: HashMap<String, u32>,
1565    /// Trail entries produced by this branch.
1566    trail: Vec<serde_json::Value>,
1567}
1568
1569impl FrontierCheckpoint {
1570    /// Update one branch slot and persist the whole record under the mutex.
1571    ///
1572    /// Holding the lock across the `save` await is what linearises the
1573    /// otherwise-concurrent blob writes (see the module-level concurrency note).
1574    async fn save_slot(
1575        &self,
1576        store: &dyn CheckpointStore,
1577        tenant: &TenantContext,
1578        slot: usize,
1579        bc: &BranchCursor,
1580    ) -> Result<(), GraphExecError> {
1581        let mut guard = self.inner.lock().await;
1582        if let Some(existing) = guard.get_mut(slot) {
1583            *existing = bc.clone();
1584        }
1585        let frontier_json = Some(serde_json::to_string(&*guard).map_err(CheckpointError::Serde)?);
1586        let trunk_state: GraphRunState =
1587            serde_json::from_str(&self.trunk_state_json).map_err(CheckpointError::Serde)?;
1588        let rec = build_record_with_frontier(
1589            &self.run_id,
1590            &self.graph_json,
1591            &self.parallel_node,
1592            &trunk_state,
1593            &self.trunk_visits,
1594            RunStatus::Running,
1595            frontier_json,
1596        )?;
1597        store.save(tenant, &rec).await?;
1598        Ok(())
1599    }
1600
1601    /// Persist the current frontier without changing any slot (initial save).
1602    async fn checkpoint(
1603        &self,
1604        store: &dyn CheckpointStore,
1605        tenant: &TenantContext,
1606    ) -> Result<(), GraphExecError> {
1607        let guard = self.inner.lock().await;
1608        let frontier_json = Some(serde_json::to_string(&*guard).map_err(CheckpointError::Serde)?);
1609        let trunk_state: GraphRunState =
1610            serde_json::from_str(&self.trunk_state_json).map_err(CheckpointError::Serde)?;
1611        let rec = build_record_with_frontier(
1612            &self.run_id,
1613            &self.graph_json,
1614            &self.parallel_node,
1615            &trunk_state,
1616            &self.trunk_visits,
1617            RunStatus::Running,
1618            frontier_json,
1619        )?;
1620        store.save(tenant, &rec).await?;
1621        Ok(())
1622    }
1623
1624    /// Fold a branch's private visit counts into the shared collector.
1625    async fn record_branch_visits(&self, visits: &HashMap<String, u32>) {
1626        let mut guard = self.branch_visits.lock().await;
1627        for (k, v) in visits {
1628            guard.insert(k.clone(), *v);
1629        }
1630    }
1631
1632    /// Snapshot the merged branch visit counts (disjoint keys by validation).
1633    async fn collect_branch_visits(&self) -> HashMap<String, u32> {
1634        self.branch_visits.lock().await.clone()
1635    }
1636}
1637
1638// ---------------------------------------------------------------------------
1639// Private helpers
1640// ---------------------------------------------------------------------------
1641
1642/// Serialise all fields into a [`GraphRunRecord`] (no parallel frontier).
1643fn build_record(
1644    run_id: &str,
1645    graph_json: &str,
1646    cursor: &str,
1647    state: &GraphRunState,
1648    visits: &HashMap<String, u32>,
1649    status: RunStatus,
1650) -> Result<GraphRunRecord, GraphExecError> {
1651    build_record_with_frontier(run_id, graph_json, cursor, state, visits, status, None)
1652}
1653
1654/// Serialise all fields into a [`GraphRunRecord`], including an optional
1655/// parallel `frontier_json`.
1656fn build_record_with_frontier(
1657    run_id: &str,
1658    graph_json: &str,
1659    cursor: &str,
1660    state: &GraphRunState,
1661    visits: &HashMap<String, u32>,
1662    status: RunStatus,
1663    frontier_json: Option<String>,
1664) -> Result<GraphRunRecord, GraphExecError> {
1665    let state_json = serde_json::to_string(state).map_err(CheckpointError::Serde)?;
1666    let visits_json = serde_json::to_string(visits).map_err(CheckpointError::Serde)?;
1667    Ok(GraphRunRecord {
1668        run_id: run_id.to_owned(),
1669        graph_json: graph_json.to_owned(),
1670        cursor: cursor.to_owned(),
1671        state_json,
1672        status,
1673        visits_json,
1674        frontier_json,
1675    })
1676}
1677
1678/// Return the single outgoing edge target, or an error if none.
1679fn next_linear(cfg: &GraphConfig, id: &str) -> Result<String, GraphExecError> {
1680    cfg.graph
1681        .edges_from(id)
1682        .next()
1683        .map(|e| e.to.clone())
1684        .ok_or_else(|| {
1685            GraphExecError::Graph(super::model::GraphError::Invalid(format!(
1686                "node '{id}' has no outgoing edge"
1687            )))
1688        })
1689}
1690
1691/// Resolve the single join node for a parallel region by forward BFS from the
1692/// parallel node, returning the first [`NodeKind::Join`] reached.
1693///
1694/// Validation guarantees every branch converges on the SAME join and that no
1695/// nested parallel exists, so the first join found is THE region's join.
1696fn find_join_for_parallel(
1697    cfg: &GraphConfig,
1698    parallel_node: &str,
1699) -> Result<String, GraphExecError> {
1700    use std::collections::HashSet;
1701    let mut visited: HashSet<String> = HashSet::new();
1702    let mut queue: Vec<String> = cfg
1703        .graph
1704        .edges_from(parallel_node)
1705        .map(|e| e.to.clone())
1706        .collect();
1707    while let Some(current) = queue.pop() {
1708        if !visited.insert(current.clone()) {
1709            continue;
1710        }
1711        match cfg.graph.node(&current) {
1712            Some(n) if matches!(n.kind, NodeKind::Join) => return Ok(current),
1713            Some(_) => {
1714                for e in cfg.graph.edges_from(&current) {
1715                    queue.push(e.to.clone());
1716                }
1717            }
1718            None => {
1719                return Err(GraphExecError::UnknownNode(current));
1720            }
1721        }
1722    }
1723    Err(GraphExecError::Graph(GraphError::Invalid(format!(
1724        "parallel node '{parallel_node}' has no reachable join node"
1725    ))))
1726}
1727
1728/// The most recent assistant message content, or empty string if none.
1729fn last_assistant_message(state: &GraphRunState) -> String {
1730    state
1731        .messages
1732        .iter()
1733        .rev()
1734        .find(|m| m.role == GraphRole::Assistant)
1735        .map(|m| m.content.clone())
1736        .unwrap_or_default()
1737}
1738
1739/// Rebuild a minimal trail from the state message log (used when returning
1740/// a stored terminal outcome without re-driving).
1741fn rebuild_trail_from_state(state: &GraphRunState) -> Vec<serde_json::Value> {
1742    state
1743        .messages
1744        .iter()
1745        .map(|m| {
1746            let kind = match m.role {
1747                GraphRole::User => "user",
1748                GraphRole::Assistant => "agent",
1749                GraphRole::Tool => "tool",
1750            };
1751            serde_json::json!({"kind": kind, "content": m.content})
1752        })
1753        .collect()
1754}
1755
1756// ---------------------------------------------------------------------------
1757// Tests
1758// ---------------------------------------------------------------------------
1759
1760#[cfg(test)]
1761#[allow(clippy::unwrap_used, clippy::expect_used)]
1762mod tests {
1763    use std::sync::Arc;
1764    use std::sync::atomic::{AtomicU32, Ordering};
1765
1766    use super::*;
1767    use crate::graph::test_fixtures::{parallel_json, supervisor_json, triage_json};
1768    use crate::graph::{GraphConfig, InMemoryCheckpointStore};
1769    use crate::tenant::TenantContext;
1770
1771    // -----------------------------------------------------------------------
1772    // Test helpers
1773    // -----------------------------------------------------------------------
1774
1775    fn tenant() -> TenantContext {
1776        TenantContext::new("test", "dev")
1777    }
1778
1779    fn triage_cfg() -> GraphConfig {
1780        GraphConfig::from_json(&triage_json()).expect("fixture is valid")
1781    }
1782
1783    fn supervisor_cfg() -> GraphConfig {
1784        GraphConfig::from_json(&supervisor_json()).expect("supervisor fixture is valid")
1785    }
1786
1787    fn parallel_cfg() -> GraphConfig {
1788        GraphConfig::from_json(&parallel_json()).expect("parallel fixture is valid")
1789    }
1790
1791    /// Build an [`AgentTurnFn`] that resolves on the n-th call (1-indexed).
1792    /// `counter` is incremented on every (non-replayed) invocation.
1793    fn agent_fn_resolves_on(counter: Arc<AtomicU32>, resolve_on_call: u32) -> AgentTurnFn {
1794        Arc::new(move |req: AgentTurnRequest| {
1795            let n = counter.fetch_add(1, Ordering::SeqCst) + 1; // 1-indexed
1796            let resolved = n >= resolve_on_call;
1797            let reply = format!("reply-{n} from {}", req.node_id);
1798            Box::pin(async move { Ok(AgentTurnResult { reply, resolved }) })
1799        })
1800    }
1801
1802    /// Always-succeed tool fn with a counter.
1803    fn tool_fn_counting(counter: Arc<AtomicU32>) -> ToolFn {
1804        Arc::new(move |_req: ToolCallRequest| {
1805            counter.fetch_add(1, Ordering::SeqCst);
1806            Box::pin(async move { Ok(serde_json::json!({"found": true})) })
1807        })
1808    }
1809
1810    /// A no-op supervisor fn that always returns an error (for v1 tests that
1811    /// never reach a supervisor node).
1812    fn supervisor_fn_unreachable() -> SupervisorFn {
1813        Arc::new(|_req: SupervisorRequest| {
1814            Box::pin(async move {
1815                Err(GraphExecError::Supervisor(
1816                    "supervisor fn should not be called in this test".into(),
1817                ))
1818            })
1819        })
1820    }
1821
1822    /// Build a supervisor fn that always routes to `branch`, counting calls.
1823    fn supervisor_fn_always_routes_to(
1824        counter: Arc<AtomicU32>,
1825        branch: &'static str,
1826    ) -> SupervisorFn {
1827        Arc::new(move |req: SupervisorRequest| {
1828            counter.fetch_add(1, Ordering::SeqCst);
1829            let node = req.node_id.clone();
1830            Box::pin(async move {
1831                Ok(SupervisorResult {
1832                    branch: branch.to_string(),
1833                    raw_reply: format!("[[ROUTE:{branch}]] from supervisor at {node}"),
1834                })
1835            })
1836        })
1837    }
1838
1839    /// A trivial approval fn that always reports `Awaiting` — a safe default
1840    /// for tests that never route through a `NodeKind::Approval` node.
1841    fn approval_fn_awaiting() -> ApprovalFn {
1842        Arc::new(|_req: ApprovalRequest| Box::pin(async move { Ok(ApprovalOutcome::Awaiting) }))
1843    }
1844
1845    /// Build an approval fn that always reports `Decided { branch }`,
1846    /// counting calls.
1847    fn approval_fn_decides(counter: Arc<AtomicU32>, branch: &'static str) -> ApprovalFn {
1848        Arc::new(move |_req: ApprovalRequest| {
1849            counter.fetch_add(1, Ordering::SeqCst);
1850            Box::pin(async move {
1851                Ok(ApprovalOutcome::Decided {
1852                    branch: branch.to_string(),
1853                })
1854            })
1855        })
1856    }
1857
1858    // -----------------------------------------------------------------------
1859    // Test 1: happy path — resolves on first agent pass
1860    // -----------------------------------------------------------------------
1861
1862    /// Path: agent → lookup → router → respond
1863    /// Agent resolves on attempt 1 → router takes "resolved" branch → Succeed.
1864    #[tokio::test]
1865    async fn happy_path_resolves_first_pass() {
1866        let store = Arc::new(InMemoryCheckpointStore::default());
1867        let agent_count = Arc::new(AtomicU32::new(0));
1868        let tool_count = Arc::new(AtomicU32::new(0));
1869
1870        let exec = GraphExecutor::new(
1871            store.clone(),
1872            agent_fn_resolves_on(agent_count.clone(), 1),
1873            tool_fn_counting(tool_count.clone()),
1874            supervisor_fn_unreachable(),
1875            approval_fn_awaiting(),
1876        );
1877
1878        let outcome = exec
1879            .start(&tenant(), "run-happy", &triage_cfg(), "help me")
1880            .await
1881            .expect("should succeed");
1882
1883        assert_eq!(outcome.status, RunStatus::Succeeded, "status");
1884        assert!(
1885            outcome.reply.contains("reply-1"),
1886            "reply should contain agent output: {:?}",
1887            outcome.reply
1888        );
1889        assert_eq!(agent_count.load(Ordering::SeqCst), 1, "agent invoked once");
1890        assert_eq!(tool_count.load(Ordering::SeqCst), 1, "tool invoked once");
1891        // Trail: agent, tool, router, respond = 4 entries
1892        assert_eq!(outcome.trail.len(), 4, "trail: {:?}", outcome.trail);
1893    }
1894
1895    // -----------------------------------------------------------------------
1896    // Test 2: loops until router cap, then resolves
1897    // -----------------------------------------------------------------------
1898
1899    /// triage_json has maxIterations=3.  Agent never self-resolves.
1900    /// After 3 iterations, router takes "resolved" branch → Succeeded.
1901    #[tokio::test]
1902    async fn loops_until_router_cap_then_resolves_via_cap() {
1903        let store = Arc::new(InMemoryCheckpointStore::default());
1904        let agent_count = Arc::new(AtomicU32::new(0));
1905
1906        // resolve_on_call = u32::MAX → never resolves on its own
1907        let exec = GraphExecutor::new(
1908            store.clone(),
1909            agent_fn_resolves_on(agent_count.clone(), u32::MAX),
1910            tool_fn_counting(Arc::new(AtomicU32::new(0))),
1911            supervisor_fn_unreachable(),
1912            approval_fn_awaiting(),
1913        );
1914
1915        let outcome = exec
1916            .start(&tenant(), "run-cap", &triage_cfg(), "loop me")
1917            .await
1918            .expect("should succeed via iteration cap");
1919
1920        assert_eq!(outcome.status, RunStatus::Succeeded);
1921        assert_eq!(
1922            agent_count.load(Ordering::SeqCst),
1923            3,
1924            "agent should be invoked exactly 3 times (maxIterations=3)"
1925        );
1926    }
1927
1928    // -----------------------------------------------------------------------
1929    // Test 3: resume on a succeeded run returns stored outcome, no re-invoke
1930    // -----------------------------------------------------------------------
1931
1932    #[tokio::test]
1933    async fn resume_on_succeeded_run_returns_stored_outcome_without_reinvoking() {
1934        let store = Arc::new(InMemoryCheckpointStore::default());
1935        let agent_count = Arc::new(AtomicU32::new(0));
1936        let tool_count = Arc::new(AtomicU32::new(0));
1937
1938        let exec = GraphExecutor::new(
1939            store.clone(),
1940            agent_fn_resolves_on(agent_count.clone(), 1),
1941            tool_fn_counting(tool_count.clone()),
1942            supervisor_fn_unreachable(),
1943            approval_fn_awaiting(),
1944        );
1945
1946        // Drive to completion.
1947        exec.start(&tenant(), "run-resume-done", &triage_cfg(), "hi")
1948            .await
1949            .expect("first run succeeds");
1950
1951        let after_start_agent = agent_count.load(Ordering::SeqCst);
1952        let after_start_tool = tool_count.load(Ordering::SeqCst);
1953
1954        // resume should return Succeeded without calling agent or tool again.
1955        let outcome = exec
1956            .resume(&tenant(), "run-resume-done")
1957            .await
1958            .expect("resume should succeed");
1959
1960        assert_eq!(outcome.status, RunStatus::Succeeded);
1961        assert_eq!(
1962            agent_count.load(Ordering::SeqCst),
1963            after_start_agent,
1964            "agent must NOT be called again on resume of terminal run"
1965        );
1966        assert_eq!(
1967            tool_count.load(Ordering::SeqCst),
1968            after_start_tool,
1969            "tool must NOT be called again on resume of terminal run"
1970        );
1971    }
1972
1973    // -----------------------------------------------------------------------
1974    // Test 4: global visit cap fails the run
1975    // -----------------------------------------------------------------------
1976
1977    /// Build a variant of triage with maxIterations=1000 (far above MAX_NODE_VISITS).
1978    /// The global cap of 64 should fire first, saving the record as Failed.
1979    #[tokio::test]
1980    async fn global_visit_cap_fails_run() {
1981        let store = Arc::new(InMemoryCheckpointStore::default());
1982
1983        // Clone triage fixture and patch maxIterations on the router node.
1984        let mut v: serde_json::Value = serde_json::from_str(&triage_json()).expect("fixture JSON");
1985        for node in v["nodes"].as_array_mut().expect("nodes array") {
1986            if node["kind"] == "router" {
1987                node["maxIterations"] = serde_json::json!(1000);
1988            }
1989        }
1990        let cfg = GraphConfig::from_json(&v.to_string()).expect("patched graph valid");
1991
1992        let exec = GraphExecutor::new(
1993            store.clone(),
1994            // never resolves
1995            agent_fn_resolves_on(Arc::new(AtomicU32::new(0)), u32::MAX),
1996            tool_fn_counting(Arc::new(AtomicU32::new(0))),
1997            supervisor_fn_unreachable(),
1998            approval_fn_awaiting(),
1999        );
2000
2001        let err = exec
2002            .start(&tenant(), "run-global-cap", &cfg, "infinite loop")
2003            .await
2004            .expect_err("should fail with IterationCap");
2005
2006        assert!(
2007            matches!(err, GraphExecError::IterationCap { .. }),
2008            "expected IterationCap, got {err:?}"
2009        );
2010
2011        // The stored record must be Failed.
2012        let rec = store
2013            .load(&tenant(), "run-global-cap")
2014            .await
2015            .expect("store accessible")
2016            .expect("record must exist");
2017        assert_eq!(
2018            rec.status,
2019            RunStatus::Failed,
2020            "stored status must be Failed"
2021        );
2022    }
2023
2024    // -----------------------------------------------------------------------
2025    // Test 5: effect error leaves run resumable; replay prevents re-invoke
2026    // -----------------------------------------------------------------------
2027
2028    /// Agent succeeds (unresolved) on attempt 1, then fails on attempt 2.
2029    /// start() returns Err; record stays Running.
2030    /// resume() with a closure that resolves on attempt 1 (= attempt 2 of the
2031    /// node, but attempt 1 of the fresh closure) → Succeeded.
2032    /// Replay must prevent attempt-1 from being re-executed.
2033    #[tokio::test]
2034    async fn effect_error_leaves_run_resumable() {
2035        let store = Arc::new(InMemoryCheckpointStore::default());
2036        let t = tenant();
2037
2038        // Phase 1: agent call #1 → ok/unresolved; call #2 → error.
2039        let phase1_count = Arc::new(AtomicU32::new(0));
2040        {
2041            let pc = phase1_count.clone();
2042            let agent_phase1: AgentTurnFn = Arc::new(move |req: AgentTurnRequest| {
2043                let n = pc.fetch_add(1, Ordering::SeqCst) + 1;
2044                let node = req.node_id.clone();
2045                Box::pin(async move {
2046                    if n == 1 {
2047                        Ok(AgentTurnResult {
2048                            reply: format!("pass-{n} from {node}"),
2049                            resolved: false,
2050                        })
2051                    } else {
2052                        Err(GraphExecError::AgentTurn(
2053                            "simulated failure on attempt 2".into(),
2054                        ))
2055                    }
2056                })
2057            });
2058
2059            let tool_count = Arc::new(AtomicU32::new(0));
2060            let store_ref = store.clone();
2061            let exec = GraphExecutor::new(
2062                store_ref,
2063                agent_phase1,
2064                tool_fn_counting(tool_count.clone()),
2065                supervisor_fn_unreachable(),
2066                approval_fn_awaiting(),
2067            );
2068
2069            let err = exec
2070                .start(&t, "run-resumable", &triage_cfg(), "retry me")
2071                .await
2072                .expect_err("should fail on agent attempt 2");
2073
2074            assert!(
2075                matches!(err, GraphExecError::AgentTurn(_)),
2076                "expected AgentTurn error, got {err:?}"
2077            );
2078
2079            // Record must still be Running.
2080            let rec = store
2081                .load(&t, "run-resumable")
2082                .await
2083                .expect("store ok")
2084                .expect("record exists");
2085            assert_eq!(
2086                rec.status,
2087                RunStatus::Running,
2088                "run should stay Running after effect error"
2089            );
2090        }
2091
2092        // Phase 2: resume with a closure that resolves on its first call
2093        // (= attempt 2 of the "agent" node, but the phase2 closure only sees
2094        // calls that were NOT replayed).
2095        let phase2_count = Arc::new(AtomicU32::new(0));
2096        let tool_phase2_count = Arc::new(AtomicU32::new(0));
2097        {
2098            let pc2 = phase2_count.clone();
2099            let agent_phase2: AgentTurnFn = Arc::new(move |_req: AgentTurnRequest| {
2100                pc2.fetch_add(1, Ordering::SeqCst);
2101                Box::pin(async move {
2102                    Ok(AgentTurnResult {
2103                        reply: "resolved!".into(),
2104                        resolved: true,
2105                    })
2106                })
2107            });
2108
2109            let exec2 = GraphExecutor::new(
2110                store.clone(),
2111                agent_phase2,
2112                tool_fn_counting(tool_phase2_count.clone()),
2113                supervisor_fn_unreachable(),
2114                approval_fn_awaiting(),
2115            );
2116
2117            let outcome = exec2
2118                .resume(&t, "run-resumable")
2119                .await
2120                .expect("resume should succeed");
2121
2122            assert_eq!(outcome.status, RunStatus::Succeeded, "outcome status");
2123        }
2124
2125        // Attempt 1 was replayed → phase2 agent called exactly once.
2126        assert_eq!(
2127            phase2_count.load(Ordering::SeqCst),
2128            1,
2129            "phase2 agent must be called exactly once (attempt-1 was replayed)"
2130        );
2131        // Attempt 1 of the tool node was replayed (already recorded in phase 1).
2132        // Attempt 2 of the tool node (second loop pass) is a new invocation.
2133        // So phase2 tool is called exactly once — for attempt 2, not for the
2134        // replayed attempt 1.
2135        assert_eq!(
2136            tool_phase2_count.load(Ordering::SeqCst),
2137            1,
2138            "phase2 tool must be called once (attempt-1 replayed, attempt-2 is fresh)"
2139        );
2140    }
2141
2142    // -----------------------------------------------------------------------
2143    // Test 6: start twice with same run id after completion → AlreadyCompleted
2144    // -----------------------------------------------------------------------
2145
2146    #[tokio::test]
2147    async fn start_twice_with_same_run_id_after_completion_errors() {
2148        let store = Arc::new(InMemoryCheckpointStore::default());
2149
2150        let exec = GraphExecutor::new(
2151            store.clone(),
2152            agent_fn_resolves_on(Arc::new(AtomicU32::new(0)), 1),
2153            tool_fn_counting(Arc::new(AtomicU32::new(0))),
2154            supervisor_fn_unreachable(),
2155            approval_fn_awaiting(),
2156        );
2157
2158        exec.start(&tenant(), "run-dup", &triage_cfg(), "first")
2159            .await
2160            .expect("first start succeeds");
2161
2162        let err = exec
2163            .start(&tenant(), "run-dup", &triage_cfg(), "second attempt")
2164            .await
2165            .expect_err("second start must fail");
2166
2167        assert!(
2168            matches!(err, GraphExecError::AlreadyCompleted(_)),
2169            "expected AlreadyCompleted, got {err:?}"
2170        );
2171    }
2172
2173    // -----------------------------------------------------------------------
2174    // Supervisor tests (Task 2)
2175    // -----------------------------------------------------------------------
2176    //
2177    // Supervisor fixture topology (from test_fixtures::supervisor_json):
2178    //
2179    //   sup (supervisor: routes=[billing, tech])
2180    //    ├─[billing]─► agent_billing ─► router_billing ─┬─[loop]──► sup
2181    //    │                                               └─[resolved]─► respond
2182    //    └─[tech]────► agent_tech    ─► router_tech    ─┬─[loop]──► sup
2183    //                                                    └─[resolved]─► respond
2184    //
2185    // The mock supervisor always routes to a fixed branch; the agent under that
2186    // branch resolves on call 1 → router takes "resolved" → respond.
2187
2188    /// Test 7: supervisor routes to "billing" branch.
2189    /// Expected path: sup → agent_billing → router_billing → respond.
2190    /// Supervisor invoked once.
2191    #[tokio::test]
2192    async fn supervisor_routes_to_billing_branch() {
2193        let store = Arc::new(InMemoryCheckpointStore::default());
2194        let sup_count = Arc::new(AtomicU32::new(0));
2195        let agent_count = Arc::new(AtomicU32::new(0));
2196
2197        let exec = GraphExecutor::new(
2198            store.clone(),
2199            agent_fn_resolves_on(agent_count.clone(), 1),
2200            tool_fn_counting(Arc::new(AtomicU32::new(0))),
2201            supervisor_fn_always_routes_to(sup_count.clone(), "billing"),
2202            approval_fn_awaiting(),
2203        );
2204
2205        let outcome = exec
2206            .start(
2207                &tenant(),
2208                "run-sup-billing",
2209                &supervisor_cfg(),
2210                "I have a billing question",
2211            )
2212            .await
2213            .expect("supervisor billing run should succeed");
2214
2215        assert_eq!(outcome.status, RunStatus::Succeeded, "status");
2216        assert_eq!(
2217            sup_count.load(Ordering::SeqCst),
2218            1,
2219            "supervisor invoked once"
2220        );
2221        assert_eq!(
2222            agent_count.load(Ordering::SeqCst),
2223            1,
2224            "agent invoked once on billing branch"
2225        );
2226
2227        // Trail should contain a supervisor entry with branch="billing".
2228        let sup_entry = outcome.trail.iter().find(|e| e["kind"] == "supervisor");
2229        assert!(
2230            sup_entry.is_some(),
2231            "trail must contain a supervisor entry: {:?}",
2232            outcome.trail
2233        );
2234        let sup_entry = sup_entry.unwrap();
2235        assert_eq!(
2236            sup_entry["branch"], "billing",
2237            "supervisor trail branch must be 'billing'"
2238        );
2239        assert_eq!(
2240            sup_entry["replayed"], false,
2241            "fresh run: supervisor not replayed"
2242        );
2243    }
2244
2245    /// Test 8: supervisor routes to "tech" branch.
2246    #[tokio::test]
2247    async fn supervisor_routes_to_tech_branch() {
2248        let store = Arc::new(InMemoryCheckpointStore::default());
2249        let sup_count = Arc::new(AtomicU32::new(0));
2250        let agent_count = Arc::new(AtomicU32::new(0));
2251
2252        let exec = GraphExecutor::new(
2253            store.clone(),
2254            agent_fn_resolves_on(agent_count.clone(), 1),
2255            tool_fn_counting(Arc::new(AtomicU32::new(0))),
2256            supervisor_fn_always_routes_to(sup_count.clone(), "tech"),
2257            approval_fn_awaiting(),
2258        );
2259
2260        let outcome = exec
2261            .start(
2262                &tenant(),
2263                "run-sup-tech",
2264                &supervisor_cfg(),
2265                "I have a tech issue",
2266            )
2267            .await
2268            .expect("supervisor tech run should succeed");
2269
2270        assert_eq!(outcome.status, RunStatus::Succeeded, "status");
2271        assert_eq!(
2272            sup_count.load(Ordering::SeqCst),
2273            1,
2274            "supervisor invoked once"
2275        );
2276        assert_eq!(
2277            agent_count.load(Ordering::SeqCst),
2278            1,
2279            "agent invoked once on tech branch"
2280        );
2281
2282        let sup_entry = outcome.trail.iter().find(|e| e["kind"] == "supervisor");
2283        assert!(sup_entry.is_some(), "trail must have supervisor entry");
2284        assert_eq!(sup_entry.unwrap()["branch"], "tech");
2285    }
2286
2287    /// Test 9: replay determinism — complete a supervisor run, then resume the
2288    /// same run_id. The supervisor closure must NOT be invoked again; the recorded
2289    /// routing decision is replayed from the ledger.
2290    #[tokio::test]
2291    async fn supervisor_replay_determinism() {
2292        let store = Arc::new(InMemoryCheckpointStore::default());
2293        let sup_count = Arc::new(AtomicU32::new(0));
2294        let agent_count = Arc::new(AtomicU32::new(0));
2295
2296        let exec = GraphExecutor::new(
2297            store.clone(),
2298            agent_fn_resolves_on(agent_count.clone(), 1),
2299            tool_fn_counting(Arc::new(AtomicU32::new(0))),
2300            supervisor_fn_always_routes_to(sup_count.clone(), "billing"),
2301            approval_fn_awaiting(),
2302        );
2303
2304        // Drive to completion.
2305        let first = exec
2306            .start(
2307                &tenant(),
2308                "run-sup-replay",
2309                &supervisor_cfg(),
2310                "billing question",
2311            )
2312            .await
2313            .expect("first run succeeds");
2314        assert_eq!(first.status, RunStatus::Succeeded);
2315
2316        let after_first_sup = sup_count.load(Ordering::SeqCst);
2317
2318        // Resume the completed run — should return the terminal outcome without
2319        // re-invoking the supervisor or agent.
2320        let second = exec
2321            .resume(&tenant(), "run-sup-replay")
2322            .await
2323            .expect("resume should succeed");
2324        assert_eq!(second.status, RunStatus::Succeeded);
2325        assert_eq!(
2326            sup_count.load(Ordering::SeqCst),
2327            after_first_sup,
2328            "supervisor must NOT be called again on resume of a terminal run"
2329        );
2330    }
2331
2332    /// Test 10: trail has the supervisor entry with branch and replayed=false on
2333    /// fresh run, and replayed=true in a mid-flight crash-recovery scenario.
2334    #[tokio::test]
2335    async fn supervisor_trail_entry_has_branch_and_replayed_flag() {
2336        let store = Arc::new(InMemoryCheckpointStore::default());
2337        let sup_count = Arc::new(AtomicU32::new(0));
2338
2339        let exec = GraphExecutor::new(
2340            store.clone(),
2341            agent_fn_resolves_on(Arc::new(AtomicU32::new(0)), 1),
2342            tool_fn_counting(Arc::new(AtomicU32::new(0))),
2343            supervisor_fn_always_routes_to(sup_count.clone(), "tech"),
2344            approval_fn_awaiting(),
2345        );
2346
2347        let outcome = exec
2348            .start(&tenant(), "run-sup-trail", &supervisor_cfg(), "need help")
2349            .await
2350            .expect("run should succeed");
2351
2352        // Find the supervisor entry in the trail.
2353        let sup_entry = outcome
2354            .trail
2355            .iter()
2356            .find(|e| e["kind"] == "supervisor")
2357            .expect("trail must contain a supervisor entry");
2358
2359        assert_eq!(sup_entry["node"], "sup", "supervisor node id");
2360        assert_eq!(sup_entry["kind"], "supervisor");
2361        assert_eq!(sup_entry["attempt"], 1u32);
2362        assert_eq!(sup_entry["replayed"], false);
2363        assert_eq!(sup_entry["branch"], "tech");
2364    }
2365
2366    // -----------------------------------------------------------------------
2367    // Provider threading tests
2368    // -----------------------------------------------------------------------
2369
2370    /// Test: when an agent node carries `"provider": "anthropic"`, the
2371    /// `AgentTurnRequest` delivered to the closure must have
2372    /// `provider = Some("anthropic")`.
2373    #[tokio::test]
2374    async fn agent_turn_request_carries_node_provider_when_set() {
2375        use std::sync::Mutex;
2376
2377        let store = Arc::new(InMemoryCheckpointStore::default());
2378        let captured: Arc<Mutex<Option<Option<String>>>> = Arc::new(Mutex::new(None));
2379        let cap = captured.clone();
2380
2381        let agent: AgentTurnFn = Arc::new(move |req: AgentTurnRequest| {
2382            *cap.lock().unwrap() = Some(req.provider.clone());
2383            Box::pin(async move {
2384                Ok(AgentTurnResult {
2385                    reply: "ok".into(),
2386                    resolved: true,
2387                })
2388            })
2389        });
2390
2391        // Graph with provider set on the agent node.
2392        let cfg_json = serde_json::json!({
2393            "schemaVersion": 1,
2394            "entry": "agent",
2395            "nodes": [
2396                {
2397                    "id": "agent",
2398                    "kind": "agent",
2399                    "systemPrompt": "You help.",
2400                    "model": "claude-3-5-sonnet",
2401                    "provider": "anthropic"
2402                },
2403                {"id": "respond", "kind": "respond"}
2404            ],
2405            "edges": [
2406                {"from": "agent", "to": "respond"}
2407            ]
2408        })
2409        .to_string();
2410        let cfg = GraphConfig::from_json(&cfg_json).expect("fixture valid");
2411
2412        let exec = GraphExecutor::new(
2413            store.clone(),
2414            agent,
2415            Arc::new(|_| Box::pin(async { Ok(serde_json::json!({})) })),
2416            supervisor_fn_unreachable(),
2417            approval_fn_awaiting(),
2418        );
2419        exec.start(&tenant(), "run-provider-set", &cfg, "hi")
2420            .await
2421            .expect("run should succeed");
2422
2423        let got = captured.lock().unwrap().take().expect("agent was called");
2424        assert_eq!(
2425            got,
2426            Some("anthropic".to_string()),
2427            "AgentTurnRequest.provider must be Some(\"anthropic\") when set on the node"
2428        );
2429    }
2430
2431    /// Test: when an agent node has NO `"provider"` field, the
2432    /// `AgentTurnRequest` must have `provider = None` (backward compat).
2433    #[tokio::test]
2434    async fn agent_turn_request_provider_is_none_when_absent() {
2435        use std::sync::Mutex;
2436
2437        let store = Arc::new(InMemoryCheckpointStore::default());
2438        let captured: Arc<Mutex<Option<Option<String>>>> = Arc::new(Mutex::new(None));
2439        let cap = captured.clone();
2440
2441        let agent: AgentTurnFn = Arc::new(move |req: AgentTurnRequest| {
2442            *cap.lock().unwrap() = Some(req.provider.clone());
2443            Box::pin(async move {
2444                Ok(AgentTurnResult {
2445                    reply: "ok".into(),
2446                    resolved: true,
2447                })
2448            })
2449        });
2450
2451        // Graph WITHOUT provider — uses the triage fixture (no provider field).
2452        let exec = GraphExecutor::new(
2453            store.clone(),
2454            agent,
2455            Arc::new(|_| Box::pin(async { Ok(serde_json::json!({})) })),
2456            supervisor_fn_unreachable(),
2457            approval_fn_awaiting(),
2458        );
2459        let cfg_json = serde_json::json!({
2460            "schemaVersion": 1,
2461            "entry": "agent",
2462            "nodes": [
2463                {
2464                    "id": "agent",
2465                    "kind": "agent",
2466                    "systemPrompt": "You help.",
2467                    "model": "gpt-4o-mini"
2468                },
2469                {"id": "respond", "kind": "respond"}
2470            ],
2471            "edges": [{"from": "agent", "to": "respond"}]
2472        })
2473        .to_string();
2474        let cfg = GraphConfig::from_json(&cfg_json).expect("fixture valid");
2475        exec.start(&tenant(), "run-provider-absent", &cfg, "hi")
2476            .await
2477            .expect("run should succeed");
2478
2479        let got = captured.lock().unwrap().take().expect("agent was called");
2480        assert_eq!(
2481            got, None,
2482            "AgentTurnRequest.provider must be None when the node has no provider field"
2483        );
2484    }
2485
2486    // -----------------------------------------------------------------------
2487    // Parallel / Join tests (Task 3)
2488    // -----------------------------------------------------------------------
2489    //
2490    // parallel_json topology:
2491    //   entry(agent) → fan(parallel) ─[a]─► agent_a(agent) ─┐
2492    //                                 ─[b]─► tool_b(tool)  ─┤
2493    //                                                        ▼
2494    //                                                  meet(join) → respond
2495    //
2496    // After the trunk visits `entry`, the trunk state holds: [user, entry-reply].
2497    // Branch "a" appends agent_a's reply; branch "b" appends tool_b's result.
2498
2499    /// Test 11: parallel happy path → Succeeded, both branch messages present
2500    /// in deterministic (label) merge order.
2501    #[tokio::test]
2502    async fn parallel_happy_path_merges_both_branches() {
2503        let store = Arc::new(InMemoryCheckpointStore::default());
2504
2505        // Agent fn replies "agent-reply from <node>"; resolves so the run can end.
2506        let agent: AgentTurnFn = Arc::new(|req: AgentTurnRequest| {
2507            let node = req.node_id.clone();
2508            Box::pin(async move {
2509                Ok(AgentTurnResult {
2510                    reply: format!("agent-reply from {node}"),
2511                    resolved: true,
2512                })
2513            })
2514        });
2515        let tool: ToolFn = Arc::new(|_req: ToolCallRequest| {
2516            Box::pin(async move { Ok(serde_json::json!({"branch_b": "done"})) })
2517        });
2518
2519        let exec = GraphExecutor::new(
2520            store.clone(),
2521            agent,
2522            tool,
2523            supervisor_fn_unreachable(),
2524            approval_fn_awaiting(),
2525        );
2526
2527        let outcome = exec
2528            .start(&tenant(), "run-par-happy", &parallel_cfg(), "go")
2529            .await
2530            .expect("parallel run should succeed");
2531
2532        assert_eq!(outcome.status, RunStatus::Succeeded, "status");
2533
2534        // Reconstruct the final state from the store to inspect merged messages.
2535        let rec = store
2536            .load(&tenant(), "run-par-happy")
2537            .await
2538            .unwrap()
2539            .unwrap();
2540        assert_eq!(rec.frontier_json, None, "frontier cleared after merge");
2541        let state: GraphRunState = serde_json::from_str(&rec.state_json).unwrap();
2542        let contents: Vec<&str> = state.messages.iter().map(|m| m.content.as_str()).collect();
2543
2544        // Both branch contributions must be present.
2545        let a_idx = contents
2546            .iter()
2547            .position(|c| c.contains("agent-reply from agent_a"))
2548            .expect("branch a message present");
2549        let b_idx = contents
2550            .iter()
2551            .position(|c| c.contains("branch_b"))
2552            .expect("branch b message present");
2553        // Deterministic merge order: branch "a" before branch "b".
2554        assert!(
2555            a_idx < b_idx,
2556            "branch a must merge before branch b: {contents:?}"
2557        );
2558    }
2559
2560    /// Test 12: branch isolation — branch B (tool_b) must NOT see branch A's
2561    /// message mid-flight. The tool closure probes its received state.
2562    #[tokio::test]
2563    async fn parallel_branch_isolation_no_cross_bleed() {
2564        let store = Arc::new(InMemoryCheckpointStore::default());
2565
2566        let agent: AgentTurnFn = Arc::new(|req: AgentTurnRequest| {
2567            let node = req.node_id.clone();
2568            Box::pin(async move {
2569                // Only agent_a yields the distinctive message branch B must never
2570                // see; entry (trunk) yields a neutral reply so the probe targets
2571                // cross-branch bleed specifically, not the shared trunk snapshot.
2572                let reply = if node == "agent_a" {
2573                    "SECRET-A-MESSAGE from agent_a".to_string()
2574                } else {
2575                    format!("neutral reply from {node}")
2576                };
2577                Ok(AgentTurnResult {
2578                    reply,
2579                    resolved: true,
2580                })
2581            })
2582        });
2583
2584        let saw_secret = Arc::new(std::sync::atomic::AtomicBool::new(false));
2585        let saw_secret_probe = saw_secret.clone();
2586        let tool: ToolFn = Arc::new(move |req: ToolCallRequest| {
2587            // Probe: does branch B's received state contain branch A's message?
2588            let leaked = req
2589                .state
2590                .messages
2591                .iter()
2592                .any(|m| m.content.contains("SECRET-A-MESSAGE"));
2593            if leaked {
2594                saw_secret_probe.store(true, Ordering::SeqCst);
2595            }
2596            Box::pin(async move { Ok(serde_json::json!({"branch_b": "done"})) })
2597        });
2598
2599        let exec = GraphExecutor::new(
2600            store.clone(),
2601            agent,
2602            tool,
2603            supervisor_fn_unreachable(),
2604            approval_fn_awaiting(),
2605        );
2606        exec.start(&tenant(), "run-par-iso", &parallel_cfg(), "go")
2607            .await
2608            .expect("run should succeed");
2609
2610        assert!(
2611            !saw_secret.load(Ordering::SeqCst),
2612            "branch B observed branch A's message — isolation violated"
2613        );
2614    }
2615
2616    /// Test 13: deterministic merge under injected delay. Branch "a" (agent_a)
2617    /// is SLOW; branch "b" (tool_b) is FAST. The merge must still be a-then-b
2618    /// (by label), not b-then-a (by completion).
2619    #[tokio::test]
2620    async fn parallel_merge_is_deterministic_under_delay() {
2621        for _ in 0..3 {
2622            let store = Arc::new(InMemoryCheckpointStore::default());
2623
2624            // Slow agent (branch a). entry is also an agent but runs in the
2625            // trunk before fan-out, so its latency does not affect ordering.
2626            let agent: AgentTurnFn = Arc::new(|req: AgentTurnRequest| {
2627                let node = req.node_id.clone();
2628                Box::pin(async move {
2629                    if node == "agent_a" {
2630                        tokio::time::sleep(std::time::Duration::from_millis(40)).await;
2631                    }
2632                    Ok(AgentTurnResult {
2633                        reply: format!("reply from {node}"),
2634                        resolved: true,
2635                    })
2636                })
2637            });
2638            // Fast tool (branch b).
2639            let tool: ToolFn = Arc::new(|_req: ToolCallRequest| {
2640                Box::pin(async move { Ok(serde_json::json!({"branch_b_fast": true})) })
2641            });
2642
2643            let exec = GraphExecutor::new(
2644                store.clone(),
2645                agent,
2646                tool,
2647                supervisor_fn_unreachable(),
2648                approval_fn_awaiting(),
2649            );
2650            exec.start(&tenant(), "run-par-det", &parallel_cfg(), "go")
2651                .await
2652                .expect("run should succeed");
2653
2654            let rec = store.load(&tenant(), "run-par-det").await.unwrap().unwrap();
2655            let state: GraphRunState = serde_json::from_str(&rec.state_json).unwrap();
2656            let contents: Vec<&str> = state.messages.iter().map(|m| m.content.as_str()).collect();
2657            let a_idx = contents
2658                .iter()
2659                .position(|c| c.contains("reply from agent_a"))
2660                .expect("branch a present");
2661            let b_idx = contents
2662                .iter()
2663                .position(|c| c.contains("branch_b_fast"))
2664                .expect("branch b present");
2665            assert!(
2666                a_idx < b_idx,
2667                "slow branch a must still merge before fast branch b: {contents:?}"
2668            );
2669        }
2670    }
2671
2672    /// Test 14: global visit cap across branches. A parallel whose branches
2673    /// would collectively exceed MAX_NODE_VISITS → Failed + IterationCap.
2674    #[tokio::test]
2675    async fn parallel_global_visit_cap_across_branches_fails() {
2676        // Build a parallel graph where each branch loops via a router with a
2677        // huge maxIterations, so the only thing that can stop it is the global
2678        // MAX_NODE_VISITS cap counted across BOTH branches.
2679        let v = serde_json::json!({
2680            "schemaVersion": 2,
2681            "entry": "fan",
2682            "nodes": [
2683                {"id": "fan", "kind": "parallel"},
2684                {"id": "agent_a", "kind": "agent", "systemPrompt": "a", "model": "m", "tools": []},
2685                {"id": "router_a", "kind": "router", "maxIterations": 1000},
2686                {"id": "agent_b", "kind": "agent", "systemPrompt": "b", "model": "m", "tools": []},
2687                {"id": "router_b", "kind": "router", "maxIterations": 1000},
2688                {"id": "meet", "kind": "join"},
2689                {"id": "respond", "kind": "respond"}
2690            ],
2691            "edges": [
2692                {"from": "fan", "to": "agent_a", "branch": "a"},
2693                {"from": "fan", "to": "agent_b", "branch": "b"},
2694                {"from": "agent_a", "to": "router_a"},
2695                {"from": "router_a", "to": "agent_a", "branch": "loop"},
2696                {"from": "router_a", "to": "meet", "branch": "resolved"},
2697                {"from": "agent_b", "to": "router_b"},
2698                {"from": "router_b", "to": "agent_b", "branch": "loop"},
2699                {"from": "router_b", "to": "meet", "branch": "resolved"},
2700                {"from": "meet", "to": "respond"}
2701            ]
2702        });
2703        let cfg = GraphConfig::from_json(&v.to_string()).expect("graph valid");
2704        let store = Arc::new(InMemoryCheckpointStore::default());
2705
2706        // Agents never resolve → branches loop until the global cap fires.
2707        let agent: AgentTurnFn = Arc::new(|req: AgentTurnRequest| {
2708            let node = req.node_id.clone();
2709            Box::pin(async move {
2710                Ok(AgentTurnResult {
2711                    reply: format!("loop from {node}"),
2712                    resolved: false,
2713                })
2714            })
2715        });
2716        let exec = GraphExecutor::new(
2717            store.clone(),
2718            agent,
2719            tool_fn_counting(Arc::new(AtomicU32::new(0))),
2720            supervisor_fn_unreachable(),
2721            approval_fn_awaiting(),
2722        );
2723
2724        let err = exec
2725            .start(&tenant(), "run-par-cap", &cfg, "loop forever")
2726            .await
2727            .expect_err("should hit global cap");
2728        assert!(
2729            matches!(err, GraphExecError::IterationCap { .. }),
2730            "expected IterationCap, got {err:?}"
2731        );
2732
2733        let rec = store.load(&tenant(), "run-par-cap").await.unwrap().unwrap();
2734        assert_eq!(rec.status, RunStatus::Failed, "run must be Failed on cap");
2735    }
2736
2737    /// Test 15: mid-branch effect error → Err returned, record stays Running,
2738    /// frontier persisted with the failed branch's cursor at its last good node.
2739    #[tokio::test]
2740    async fn parallel_mid_branch_error_keeps_run_running_with_frontier() {
2741        let store = Arc::new(InMemoryCheckpointStore::default());
2742
2743        // Branch a (agent_a) errors; branch b (tool_b) succeeds and parks.
2744        let agent: AgentTurnFn = Arc::new(|req: AgentTurnRequest| {
2745            let node = req.node_id.clone();
2746            Box::pin(async move {
2747                if node == "agent_a" {
2748                    Err(GraphExecError::AgentTurn("branch a boom".into()))
2749                } else {
2750                    Ok(AgentTurnResult {
2751                        reply: format!("ok from {node}"),
2752                        resolved: true,
2753                    })
2754                }
2755            })
2756        });
2757        let tool: ToolFn = Arc::new(|_req: ToolCallRequest| {
2758            Box::pin(async move { Ok(serde_json::json!({"branch_b": "ok"})) })
2759        });
2760
2761        let exec = GraphExecutor::new(
2762            store.clone(),
2763            agent,
2764            tool,
2765            supervisor_fn_unreachable(),
2766            approval_fn_awaiting(),
2767        );
2768        let err = exec
2769            .start(&tenant(), "run-par-err", &parallel_cfg(), "go")
2770            .await
2771            .expect_err("branch a error should propagate");
2772        assert!(
2773            matches!(err, GraphExecError::AgentTurn(_)),
2774            "expected AgentTurn error, got {err:?}"
2775        );
2776
2777        let rec = store.load(&tenant(), "run-par-err").await.unwrap().unwrap();
2778        assert_eq!(
2779            rec.status,
2780            RunStatus::Running,
2781            "run must stay Running after a mid-branch error"
2782        );
2783        let frontier_json = rec
2784            .frontier_json
2785            .as_ref()
2786            .expect("frontier must be persisted mid-parallel");
2787        let frontier: Vec<BranchCursor> = serde_json::from_str(frontier_json).unwrap();
2788        // Branch a never advanced past agent_a (cursor stays at agent_a, not parked).
2789        let a = frontier
2790            .iter()
2791            .find(|b| b.branch == "a")
2792            .expect("branch a slot present");
2793        assert_eq!(
2794            a.cursor, "agent_a",
2795            "failed branch cursor at last good node"
2796        );
2797        assert!(!a.parked, "failed branch must not be parked");
2798    }
2799
2800    /// Test 16: resume after a mid-branch error completes the run without
2801    /// re-running the already-parked branch (in-process resume).
2802    #[tokio::test]
2803    async fn parallel_resume_after_branch_error_completes() {
2804        let store = Arc::new(InMemoryCheckpointStore::default());
2805        let t = tenant();
2806
2807        // Phase 1: branch a errors.
2808        {
2809            let agent: AgentTurnFn = Arc::new(|req: AgentTurnRequest| {
2810                let node = req.node_id.clone();
2811                Box::pin(async move {
2812                    if node == "agent_a" {
2813                        Err(GraphExecError::AgentTurn("boom".into()))
2814                    } else {
2815                        Ok(AgentTurnResult {
2816                            reply: format!("ok from {node}"),
2817                            resolved: true,
2818                        })
2819                    }
2820                })
2821            });
2822            let tool: ToolFn =
2823                Arc::new(|_r| Box::pin(async move { Ok(serde_json::json!({"branch_b": "ok"})) }));
2824            let exec = GraphExecutor::new(
2825                store.clone(),
2826                agent,
2827                tool,
2828                supervisor_fn_unreachable(),
2829                approval_fn_awaiting(),
2830            );
2831            exec.start(&t, "run-par-resume", &parallel_cfg(), "go")
2832                .await
2833                .expect_err("phase 1 errors");
2834        }
2835
2836        // Phase 2: resume with a healthy agent → branch a now completes.
2837        let branch_b_calls = Arc::new(AtomicU32::new(0));
2838        {
2839            let agent: AgentTurnFn = Arc::new(|req: AgentTurnRequest| {
2840                let node = req.node_id.clone();
2841                Box::pin(async move {
2842                    Ok(AgentTurnResult {
2843                        reply: format!("recovered from {node}"),
2844                        resolved: true,
2845                    })
2846                })
2847            });
2848            let bcalls = branch_b_calls.clone();
2849            let tool: ToolFn = Arc::new(move |_r| {
2850                bcalls.fetch_add(1, Ordering::SeqCst);
2851                Box::pin(async move { Ok(serde_json::json!({"branch_b": "ok"})) })
2852            });
2853            let exec = GraphExecutor::new(
2854                store.clone(),
2855                agent,
2856                tool,
2857                supervisor_fn_unreachable(),
2858                approval_fn_awaiting(),
2859            );
2860            let outcome = exec
2861                .resume(&t, "run-par-resume")
2862                .await
2863                .expect("resume should complete");
2864            assert_eq!(outcome.status, RunStatus::Succeeded, "resumed run succeeds");
2865        }
2866
2867        // Branch b was already parked + recorded in phase 1; resume must replay
2868        // it from the ledger and NOT re-invoke the tool.
2869        assert_eq!(
2870            branch_b_calls.load(Ordering::SeqCst),
2871            0,
2872            "already-parked branch b must replay, not re-invoke its tool"
2873        );
2874
2875        // Final merged state must contain both branches' contributions.
2876        let rec = store.load(&t, "run-par-resume").await.unwrap().unwrap();
2877        assert_eq!(rec.status, RunStatus::Succeeded);
2878        assert_eq!(rec.frontier_json, None, "frontier cleared after merge");
2879        let state: GraphRunState = serde_json::from_str(&rec.state_json).unwrap();
2880        let contents: Vec<&str> = state.messages.iter().map(|m| m.content.as_str()).collect();
2881        assert!(
2882            contents
2883                .iter()
2884                .any(|c| c.contains("recovered from agent_a")),
2885            "branch a recovered: {contents:?}"
2886        );
2887        assert!(
2888            contents.iter().any(|c| c.contains("branch_b")),
2889            "branch b present: {contents:?}"
2890        );
2891    }
2892
2893    // -----------------------------------------------------------------------
2894    // Approval tests (Task C2)
2895    // -----------------------------------------------------------------------
2896    //
2897    // approval_cfg topology: start(agent) → approval → respond
2898    // (edge label "approved" on the approval node's only outgoing edge).
2899
2900    fn approval_json() -> String {
2901        serde_json::json!({
2902            "schemaVersion": 2,
2903            "entry": "start",
2904            "nodes": [
2905                {"id": "start", "kind": "agent", "systemPrompt": "greet the user", "model": "gpt-4o-mini"},
2906                {"id": "approval", "kind": "approval", "title": "Approve refund?", "mode": "always"},
2907                {"id": "respond", "kind": "respond"}
2908            ],
2909            "edges": [
2910                {"from": "start", "to": "approval"},
2911                {"from": "approval", "to": "respond", "branch": "approved"}
2912            ]
2913        })
2914        .to_string()
2915    }
2916
2917    fn approval_cfg() -> GraphConfig {
2918        GraphConfig::from_json(&approval_json()).expect("approval fixture is valid")
2919    }
2920
2921    /// Test 17: an approval node with an `Awaiting` `ApprovalFn` parks the run
2922    /// (`RunStatus::AwaitingInput`, cursor at the approval node); resuming with
2923    /// a `Decided` `ApprovalFn` advances along the matching edge to `respond`.
2924    #[tokio::test]
2925    async fn approval_parks_then_resumes() {
2926        let store = Arc::new(InMemoryCheckpointStore::default());
2927        let t = tenant();
2928        let agent_count = Arc::new(AtomicU32::new(0));
2929
2930        // Phase 1: park.
2931        {
2932            let exec = GraphExecutor::new(
2933                store.clone(),
2934                agent_fn_resolves_on(agent_count.clone(), 1),
2935                tool_fn_counting(Arc::new(AtomicU32::new(0))),
2936                supervisor_fn_unreachable(),
2937                approval_fn_awaiting(),
2938            );
2939
2940            let outcome = exec
2941                .start(&t, "run-approval", &approval_cfg(), "please approve this")
2942                .await
2943                .expect("start should return cleanly when parked, not error");
2944
2945            assert_eq!(
2946                outcome.status,
2947                RunStatus::AwaitingInput,
2948                "start() outcome must report AwaitingInput"
2949            );
2950
2951            let rec = store
2952                .load(&t, "run-approval")
2953                .await
2954                .expect("store accessible")
2955                .expect("record must exist");
2956            assert_eq!(
2957                rec.status,
2958                RunStatus::AwaitingInput,
2959                "persisted record must be AwaitingInput"
2960            );
2961            assert_eq!(
2962                rec.cursor, "approval",
2963                "cursor must stay at the approval node while parked"
2964            );
2965        }
2966
2967        // Phase 2: resume with a decision.
2968        let decision_count = Arc::new(AtomicU32::new(0));
2969        {
2970            let exec = GraphExecutor::new(
2971                store.clone(),
2972                agent_fn_resolves_on(agent_count.clone(), 1),
2973                tool_fn_counting(Arc::new(AtomicU32::new(0))),
2974                supervisor_fn_unreachable(),
2975                approval_fn_decides(decision_count.clone(), "approved"),
2976            );
2977
2978            let outcome = exec
2979                .resume(&t, "run-approval")
2980                .await
2981                .expect("resume with a decision should succeed");
2982
2983            assert_eq!(
2984                outcome.status,
2985                RunStatus::Succeeded,
2986                "resumed run must reach Succeeded at respond"
2987            );
2988        }
2989
2990        assert_eq!(
2991            decision_count.load(Ordering::SeqCst),
2992            1,
2993            "approval fn must be called exactly once on resume"
2994        );
2995
2996        let rec = store
2997            .load(&t, "run-approval")
2998            .await
2999            .unwrap()
3000            .expect("record must exist after resume");
3001        assert_eq!(rec.status, RunStatus::Succeeded);
3002        assert_eq!(rec.cursor, "respond", "cursor must land on respond node");
3003
3004        // The visit ledger must have recorded the decision payload.
3005        let visit = store
3006            .load_node_visit(&t, "run-approval", "approval", 1)
3007            .await
3008            .unwrap()
3009            .expect("approval node visit must be recorded");
3010        assert_eq!(visit["decision"], "approved");
3011    }
3012}