Skip to main content

beam_core/
workflow_runtime.rs

1use std::fs;
2use std::path::PathBuf;
3use std::time::{Duration, Instant};
4
5use anyhow::{Context, Result};
6use async_trait::async_trait;
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9use tokio::task::JoinSet;
10use tokio::time::MissedTickBehavior;
11
12use crate::workflow_binding::{
13    BindingContext, LoopContext, resolve_bindings, resolve_bound_string,
14};
15use crate::workflow_definition::{HostExecutorNode, SubagentNode};
16use crate::workflow_orchestrator::OrchestratorAction;
17use crate::workflow_sidecar::write_effect_input_sidecar;
18use crate::{
19    EventDraft, EventLog, RunSnapshotDTO, RunStatus, WorkflowActor, WorkflowDefinition,
20    WorkflowNode, WorkflowOutputRef, decide_next_actions, read_run_snapshot,
21};
22
23#[derive(Debug, Clone)]
24pub struct WorkflowRuntimeContext {
25    pub log: EventLog,
26    pub def: WorkflowDefinition,
27    pub runs_base_dir: PathBuf,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct WorkflowDispatchSession {
32    pub session_id: String,
33    pub bot_name: String,
34    pub started_at: u64,
35    pub ended_at: Option<u64>,
36    pub cli_session_id: Option<String>,
37    pub lark_app_id: Option<String>,
38    pub cli_id: Option<String>,
39    pub working_dir: Option<String>,
40    pub web_port: Option<u16>,
41    pub log_path: Option<String>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum WorkflowDispatchOutcome {
46    Succeeded {
47        output: Value,
48        session: Option<WorkflowDispatchSession>,
49    },
50    Failed {
51        error_code: String,
52        error_class: String,
53        error_message: String,
54        session: Option<WorkflowDispatchSession>,
55    },
56    Cancelled {
57        cancel_origin_event_id: String,
58        session: Option<WorkflowDispatchSession>,
59    },
60}
61
62#[derive(Debug, Clone)]
63pub struct WorkflowDispatchRun<'a> {
64    pub run_id: &'a str,
65    pub workflow_id: &'a str,
66    pub revision_id: &'a str,
67    pub activity_id: &'a str,
68    pub attempt_id: &'a str,
69    pub node_id: &'a str,
70}
71
72/// The result of preparing a host-executor call: parsed input, canonical
73/// (effect) input, and provider metadata.  The runtime writes the canonical
74/// input to `effect-input.json`, emits `effectAttempted`, and then passes the
75/// parsed input to `execute_host_executor`.
76#[derive(Debug, Clone)]
77pub struct HostExecutorPrepareResult {
78    /// Input after executor-specific parsing/validation (feeds `execute_host_executor`).
79    pub parsed_input: Value,
80    /// Canonical, deterministic form of the effect input (feeds `effect-input.json`
81    /// and the `inputHash` in `effectAttempted`).
82    pub canonical_input: Value,
83    /// Provider identifier for the `effectAttempted` event (e.g. `"feishu-im"`).
84    pub provider: String,
85    /// Idempotency TTL in milliseconds for the `effectAttempted` event.
86    pub idempotency_ttl_ms: u64,
87}
88
89#[async_trait]
90pub trait WorkflowExecutionHooks {
91    async fn execute_subagent(
92        &mut self,
93        ctx: WorkflowDispatchRun<'_>,
94        node: &SubagentNode,
95        resolved_prompt: String,
96    ) -> Result<WorkflowDispatchOutcome>;
97
98    async fn execute_host_executor(
99        &mut self,
100        ctx: WorkflowDispatchRun<'_>,
101        node: &HostExecutorNode,
102        // Parsed input as returned by `prepare_host_executor`.
103        parsed_input: Value,
104    ) -> Result<WorkflowDispatchOutcome>;
105
106    /// Prepare a host-executor call: parse/validate the resolved input and
107    /// return the parsed form, the canonical (effect) form, and the provider
108    /// metadata.  Called by the runtime **before** writing `effect-input.json`
109    /// and emitting `effectAttempted`.
110    ///
111    /// The default implementation uses `get_host_executor_provider_meta` for
112    /// provider/TTL and treats `resolved_input` as both parsed and canonical
113    /// input — matching the legacy behaviour.
114    fn prepare_host_executor(
115        &self,
116        executor_name: &str,
117        resolved_input: &Value,
118    ) -> Result<HostExecutorPrepareResult> {
119        let (provider, idempotency_ttl_ms) = get_host_executor_provider_meta(executor_name);
120        Ok(HostExecutorPrepareResult {
121            parsed_input: resolved_input.clone(),
122            canonical_input: resolved_input.clone(),
123            provider: provider.to_string(),
124            idempotency_ttl_ms,
125        })
126    }
127
128    /// Attempt to recover dangling effects before the next tick.
129    ///
130    /// Called by `run_loop` before each `run_tick` to resolve any activities
131    /// that were left in a dangling state (e.g. `effectAttempted` was written
132    /// but the daemon crashed before writing a terminal event).
133    ///
134    /// The hook should inspect `snapshot.dangling.effect_attempted` and, for
135    /// each matching provider, attempt reconciliation (idempotent re-submit,
136    /// read-only lookup, or manual failure).  It writes events directly to
137    /// `log`.  The runtime will re-read the snapshot on the next loop
138    /// iteration, picking up any terminal events written here.
139    ///
140    /// The default implementation does nothing (no recovery).
141    async fn recover_dangling_effects(
142        &mut self,
143        _log: &mut EventLog,
144        snapshot: &RunSnapshotDTO,
145    ) -> Result<RecoveryResult> {
146        Ok(RecoveryResult {
147            had_progress: false,
148            has_remaining_dangling: !snapshot.dangling.effect_attempted.is_empty(),
149        })
150    }
151
152    /// Called after `check_pending_cancels` has written cancel events for the
153    /// given activities and nodes.  The hook receives the lists of activity IDs
154    /// that were just cancelled, allowing daemon-level cancellation registries
155    /// to cancel active dispatch tokens.
156    ///
157    /// Default implementation is a no-op.
158    async fn on_activities_cancelled(
159        &mut self,
160        _activity_ids: &[String],
161        _node_ids: &[String],
162        _run_id: &str,
163    ) {
164    }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct RunTickResult {
169    pub actions: usize,
170    pub snapshot: RunSnapshotDTO,
171}
172
173#[derive(Debug, Clone)]
174struct ScheduledAction {
175    action: OrchestratorAction,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum RunLoopStopReason {
180    Terminal,
181    NoProgress,
182    AwaitingWait,
183    MaxTicks,
184}
185
186/// Result of a recovery attempt during run_loop's pre-tick recovery phase.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct RecoveryResult {
189    /// Whether any events were written (i.e., recovery made progress).
190    pub had_progress: bool,
191    /// Whether there are still unrecovered dangling effects after this attempt.
192    pub has_remaining_dangling: bool,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct RunLoopResult {
197    pub reason: RunLoopStopReason,
198    pub ticks: usize,
199    pub last_snapshot: RunSnapshotDTO,
200}
201
202pub async fn run_tick<H: WorkflowExecutionHooks + Clone + Send + 'static>(
203    rt: &mut WorkflowRuntimeContext,
204    hooks: &mut H,
205    max_concurrency: usize,
206) -> Result<RunTickResult> {
207    let snapshot = read_snapshot(rt).await?;
208    if matches!(
209        snapshot.run.status,
210        RunStatus::Succeeded | RunStatus::Failed | RunStatus::Cancelled
211    ) {
212        return Ok(RunTickResult {
213            actions: 0,
214            snapshot,
215        });
216    }
217
218    if snapshot_has_pending_cancel(&snapshot) {
219        return Ok(RunTickResult {
220            actions: 0,
221            snapshot,
222        });
223    }
224
225    let actions = select_tick_actions(
226        decide_next_actions(&snapshot, &rt.def),
227        &rt.def,
228        max_concurrency,
229    );
230    if actions.is_empty() {
231        return Ok(RunTickResult {
232            actions: 0,
233            snapshot,
234        });
235    }
236
237    let mut join_set: JoinSet<Result<()>> = JoinSet::new();
238    for scheduled in actions.into_iter() {
239        let mut rt_clone = rt.clone();
240        let mut hooks_clone = hooks.clone();
241        join_set.spawn(async move {
242            apply_orchestrator_action(&mut rt_clone, &mut hooks_clone, scheduled.action).await
243        });
244    }
245
246    let mut applied = 0usize;
247    let mut cancel_poll = tokio::time::interval(Duration::from_millis(20));
248    cancel_poll.set_missed_tick_behavior(MissedTickBehavior::Skip);
249    let mut cancel_seen = false;
250    let mut cancel_abort_deadline: Option<Instant> = None;
251
252    while !join_set.is_empty() {
253        tokio::select! {
254            result = join_set.join_next() => {
255                let Some(result) = result else {
256                    break;
257                };
258                match result {
259                    Ok(Ok(())) => {
260                        applied += 1;
261                        let snapshot = read_snapshot(rt).await?;
262                        if snapshot_has_pending_cancel(&snapshot)
263                            || matches!(
264                                snapshot.run.status,
265                                RunStatus::Succeeded | RunStatus::Failed | RunStatus::Cancelled
266                            )
267                        {
268                            if snapshot_has_pending_cancel(&snapshot) && !cancel_seen {
269                                cancel_seen = true;
270                                cancel_abort_deadline =
271                                    Some(Instant::now() + Duration::from_millis(120));
272                            }
273                            if matches!(
274                                snapshot.run.status,
275                                RunStatus::Succeeded | RunStatus::Failed | RunStatus::Cancelled
276                            ) {
277                                join_set.abort_all();
278                                return Ok(RunTickResult {
279                                    actions: applied,
280                                    snapshot,
281                                });
282                            }
283                        }
284                    }
285                    Ok(Err(err)) => {
286                        if cancel_seen {
287                            continue;
288                        }
289                        join_set.abort_all();
290                        return Err(err);
291                    }
292                    Err(err) => {
293                        if cancel_seen {
294                            continue;
295                        }
296                        join_set.abort_all();
297                        return Err(anyhow::anyhow!(err));
298                    }
299                }
300            }
301            _ = cancel_poll.tick(), if !cancel_seen => {
302                let snapshot = read_snapshot(rt).await?;
303                if snapshot_has_pending_cancel(&snapshot) {
304                    cancel_seen = true;
305                    cancel_abort_deadline = Some(Instant::now() + Duration::from_millis(120));
306                    join_set.abort_all();
307                }
308            }
309            _ = tokio::time::sleep(Duration::from_millis(20)), if cancel_seen => {
310                if let Some(deadline) = cancel_abort_deadline {
311                    if Instant::now() >= deadline {
312                        join_set.abort_all();
313                    }
314                }
315            }
316        }
317    }
318
319    let snapshot = read_snapshot(rt).await?;
320    Ok(RunTickResult {
321        actions: applied,
322        snapshot,
323    })
324}
325
326pub async fn run_loop<H: WorkflowExecutionHooks + Clone + Send + 'static>(
327    rt: &mut WorkflowRuntimeContext,
328    hooks: &mut H,
329    max_ticks: usize,
330    max_concurrency: usize,
331) -> Result<RunLoopResult> {
332    let mut ticks = 0usize;
333    loop {
334        if ticks >= max_ticks {
335            let snapshot = read_snapshot(rt).await?;
336            return Ok(RunLoopResult {
337                reason: RunLoopStopReason::MaxTicks,
338                ticks,
339                last_snapshot: snapshot,
340            });
341        }
342
343        check_pending_cancels(rt, hooks).await?;
344
345        // --- Recovery phase: handle dangling effects before decide_next_actions ---
346        // This ensures that crashed/restarted workflows with dangling
347        // effectAttempted activities get reconciled before any new work is
348        // dispatched.  If recovery writes events, we re-read the snapshot and
349        // restart the loop (without incrementing ticks) so the orchestrator
350        // sees the updated state.
351        let pre_recovery_snapshot = read_snapshot(rt).await?;
352        if matches!(
353            pre_recovery_snapshot.run.status,
354            RunStatus::Succeeded | RunStatus::Failed | RunStatus::Cancelled
355        ) {
356            return Ok(RunLoopResult {
357                reason: RunLoopStopReason::Terminal,
358                ticks,
359                last_snapshot: pre_recovery_snapshot,
360            });
361        }
362
363        if !pre_recovery_snapshot.dangling.effect_attempted.is_empty() {
364            let recovery = hooks
365                .recover_dangling_effects(&mut rt.log, &pre_recovery_snapshot)
366                .await?;
367            if recovery.had_progress {
368                // Recovery wrote events — re-read the snapshot on the next
369                // iteration and re-evaluate (replay → decide_next_actions).
370                continue;
371            }
372            // Recovery could not make progress (e.g. transient provider
373            // errors).  Fall through to run_tick so the orchestrator can
374            // determine whether other dispatchable actions exist or whether
375            // the loop should stop with NoProgress / AwaitingWait.
376        }
377
378        // --- Wait resolution recovery: materialise terminal events for
379        //     activities where waitResolved / waitDeadlineExceeded was written
380        //     but the terminal (activitySucceeded / activityFailed) is missing.
381        //     This is deterministic (no external calls needed), so it runs
382        //     inline in run_loop rather than through the hooks. ---
383        if !pre_recovery_snapshot.dangling.wait_resolutions.is_empty() {
384            let had_progress = resolve_wait_terminals(rt, &pre_recovery_snapshot).await?;
385            if had_progress {
386                continue;
387            }
388        }
389
390        let tick = run_tick(rt, hooks, max_concurrency).await?;
391        ticks += 1;
392        if tick.snapshot.run.status == RunStatus::Succeeded
393            || tick.snapshot.run.status == RunStatus::Failed
394            || tick.snapshot.run.status == RunStatus::Cancelled
395        {
396            return Ok(RunLoopResult {
397                reason: RunLoopStopReason::Terminal,
398                ticks,
399                last_snapshot: tick.snapshot,
400            });
401        }
402        if tick.actions == 0 {
403            let has_waits = !tick.snapshot.dangling.waits.is_empty()
404                && tick
405                    .snapshot
406                    .dangling
407                    .waits
408                    .iter()
409                    .any(|w| !tick.snapshot.dangling.cancels.contains(w));
410            let reason = if has_waits {
411                RunLoopStopReason::AwaitingWait
412            } else {
413                RunLoopStopReason::NoProgress
414            };
415            return Ok(RunLoopResult {
416                reason,
417                ticks,
418                last_snapshot: tick.snapshot,
419            });
420        }
421    }
422}
423
424async fn check_pending_cancels<H: WorkflowExecutionHooks + Send>(
425    rt: &mut WorkflowRuntimeContext,
426    hooks: &mut H,
427) -> Result<()> {
428    let _events = rt.log.read_all()?;
429    let snapshot = read_snapshot(rt).await?;
430    let mut cancelled_activities: Vec<String> = Vec::new();
431    let mut cancelled_nodes: Vec<String> = Vec::new();
432
433    for activity_id in &snapshot.dangling.cancels {
434        cancelled_activities.push(activity_id.clone());
435        let attempt_id = snapshot
436            .activities
437            .iter()
438            .find(|a| &a.activity_id == activity_id)
439            .and_then(|a| a.current_attempt_id.clone())
440            .unwrap_or_else(|| format!("{}-attempt-1", activity_id));
441        let origin = snapshot
442            .run
443            .cancelled_run_intent
444            .as_ref()
445            .map(|i| i.cancel_origin_event_id.clone())
446            .or_else(|| {
447                snapshot
448                    .run
449                    .cancelled_node_intents
450                    .values()
451                    .next()
452                    .map(|i| i.cancel_origin_event_id.clone())
453            })
454            .unwrap_or_default();
455        let _ = crate::complete_activity_cancel(
456            &mut rt.log,
457            crate::CompleteActivityCancelInput {
458                activity_id: activity_id.clone(),
459                attempt_id,
460                cancel_origin_event_id: origin,
461            },
462            WorkflowActor::Scheduler,
463        )
464        .await;
465    }
466
467    if let Some(ref intent) = snapshot.run.cancelled_run_intent {
468        if snapshot.run.status != RunStatus::Cancelled {
469            let _ = crate::complete_run_cancel(
470                &mut rt.log,
471                crate::CompleteRunCancelInput {
472                    cancel_origin_event_id: intent.cancel_origin_event_id.clone(),
473                },
474                WorkflowActor::Scheduler,
475            )
476            .await;
477        }
478    }
479
480    if !snapshot.run.cancelled_node_intents.is_empty() {
481        for (node_id, intent) in &snapshot.run.cancelled_node_intents {
482            cancelled_nodes.push(node_id.clone());
483            let _ = crate::complete_node_cancel(
484                &mut rt.log,
485                crate::CompleteNodeCancelInput {
486                    node_id: node_id.clone(),
487                    cancel_origin_event_id: intent.cancel_origin_event_id.clone(),
488                },
489                WorkflowActor::Scheduler,
490            )
491            .await;
492        }
493    }
494
495    // Notify hooks so daemon can cancel active dispatch tokens.
496    if !cancelled_activities.is_empty() || !cancelled_nodes.is_empty() {
497        hooks
498            .on_activities_cancelled(&cancelled_activities, &cancelled_nodes, &rt.log.run_id)
499            .await;
500    }
501
502    Ok(())
503}
504
505fn snapshot_has_pending_cancel(snapshot: &RunSnapshotDTO) -> bool {
506    snapshot.run.cancelled_run_intent.is_some() || !snapshot.run.cancelled_node_intents.is_empty()
507}
508
509fn select_tick_actions(
510    actions: Vec<OrchestratorAction>,
511    def: &WorkflowDefinition,
512    max_concurrency: usize,
513) -> Vec<ScheduledAction> {
514    let limit = max_concurrency.max(1);
515    let mut selected = Vec::new();
516    let mut seen = std::collections::HashSet::new();
517    let mut dispatch_count: usize = 0;
518    for action in actions {
519        let serialization_key = action_serialization_key(def, &action);
520        if seen.insert(serialization_key.clone()) {
521            let is_dispatch = action.is_dispatch();
522            // Settle actions (FinishLoop, CompleteNodeSucceeded, etc.)
523            // don't count against the concurrency limit — they are fast
524            // and may need to be dispatched in pairs (e.g. FinishLoopIteration
525            // + FinishLoop for the same loop node).
526            if !is_dispatch || dispatch_count < limit {
527                if is_dispatch {
528                    dispatch_count += 1;
529                }
530                selected.push(ScheduledAction { action });
531            }
532        }
533    }
534    selected
535}
536
537fn action_serialization_key(_def: &WorkflowDefinition, action: &OrchestratorAction) -> String {
538    match action {
539        OrchestratorAction::DispatchWork { node_id, node, .. } => {
540            let bot_key = match node {
541                WorkflowNode::Subagent(node) => Some(format!("bot:{}", node.bot)),
542                WorkflowNode::HostExecutor(node) => Some(format!("executor:{}", node.executor)),
543                WorkflowNode::Loop(_) | WorkflowNode::Decision(_) => None,
544            };
545            bot_key.unwrap_or_else(|| format!("node:{node_id}"))
546        }
547        OrchestratorAction::DispatchGate { node_id, .. } => format!("gate:{node_id}"),
548        OrchestratorAction::CompleteNodeSucceeded { node_id, .. }
549        | OrchestratorAction::CompleteNodeFailed { node_id, .. } => {
550            format!("node:{node_id}")
551        }
552        OrchestratorAction::CompleteRunSucceeded { sink_node_id, .. } => {
553            format!("run:{sink_node_id}:succeeded")
554        }
555        OrchestratorAction::CompleteRunFailed { failed_node_id } => {
556            format!("run:{failed_node_id}:failed")
557        }
558        OrchestratorAction::StartLoop { node_id, .. } => {
559            format!("loop:start:{node_id}")
560        }
561        OrchestratorAction::StartLoopIteration { node_id, .. } => {
562            format!("loop:iter-start:{node_id}")
563        }
564        OrchestratorAction::FinishLoopIteration { node_id, .. } => {
565            format!("loop:iter-finish:{node_id}")
566        }
567        OrchestratorAction::FinishLoop { node_id, .. } => {
568            format!("loop:finish:{node_id}")
569        }
570    }
571}
572
573pub async fn dispatch_gate(
574    rt: &mut WorkflowRuntimeContext,
575    action: &crate::OrchestratorAction,
576) -> Result<()> {
577    match action {
578        OrchestratorAction::DispatchGate {
579            node_id,
580            activity_id,
581            human_gate,
582        } => {
583            let attempt_id = gate_attempt_id(activity_id);
584            let input_ref = write_json_blob(
585                &mut rt.log,
586                serde_json::json!({
587                    "kind": "human-gate",
588                    "prompt": human_gate.prompt,
589                    "approvers": human_gate.approvers,
590                }),
591            )?;
592            rt.log.append(EventDraft {
593                event_type: "attemptCreated".to_string(),
594                actor: WorkflowActor::Scheduler,
595                payload: serde_json::json!({
596                    "nodeId": node_id,
597                    "activityId": activity_id,
598                    "attemptId": attempt_id,
599                    "attemptNumber": 1,
600                    "inputRef": input_ref,
601                }),
602                timestamp: None,
603                payload_hash: None,
604            })?;
605
606            let snap = read_snapshot(rt).await?;
607            let ctx = BindingContext {
608                snapshot: &snap,
609                def: &rt.def,
610                run_dir: &rt.log.run_dir,
611                loop_context: loop_context_from_activity(activity_id),
612            };
613            let resolved_prompt = resolve_bound_string(&human_gate.prompt, &ctx).await?;
614            let prompt_field = split_prompt(&mut rt.log, &resolved_prompt)?;
615            let _ = crate::create_wait(
616                &mut rt.log,
617                crate::CreateWaitInput {
618                    activity_id: activity_id.clone(),
619                    attempt_id,
620                    node_id: node_id.clone(),
621                    wait_kind: crate::WaitKind::HumanGate,
622                    deadline_at: human_gate.deadline_ms.map(|ms| now_ms() + ms),
623                    prompt: prompt_field.prompt,
624                    prompt_ref: prompt_field.prompt_ref,
625                    prompt_preview: prompt_field.prompt_preview,
626                    approvers: human_gate.approvers.clone(),
627                    on_timeout: human_gate.on_timeout.as_deref().map(|v| match v {
628                        "success" => crate::WaitOnTimeout::Success,
629                        _ => crate::WaitOnTimeout::Fail,
630                    }),
631                },
632            )
633            .await?;
634            Ok(())
635        }
636        _ => anyhow::bail!("dispatch_gate called with non-gate action"),
637    }
638}
639
640pub async fn dispatch_work<H: WorkflowExecutionHooks>(
641    rt: &mut WorkflowRuntimeContext,
642    hooks: &mut H,
643    action: &crate::OrchestratorAction,
644) -> Result<WorkflowDispatchOutcome> {
645    match action {
646        OrchestratorAction::DispatchWork {
647            node_id,
648            activity_id,
649            node,
650        } => {
651            let attempt_id = work_attempt_id(activity_id, 1);
652            let input_ref = write_json_blob(
653                &mut rt.log,
654                serde_json::json!({
655                    "kind": match node {
656                        WorkflowNode::Subagent(_) => "subagent",
657                        WorkflowNode::HostExecutor(_) => "hostExecutor",
658                        WorkflowNode::Loop(_) => "loop",
659                        WorkflowNode::Decision(_) => "decision",
660                    },
661                    "bot_or_executor": match node {
662                        WorkflowNode::Subagent(n) => Value::String(n.bot.clone()),
663                        WorkflowNode::HostExecutor(n) => Value::String(n.executor.clone()),
664                        _ => Value::Null,
665                    },
666                    "prompt_or_input": match node {
667                        WorkflowNode::Subagent(n) => n.prompt.clone(),
668                        WorkflowNode::HostExecutor(n) => n.input.clone(),
669                        _ => Value::Null,
670                    }
671                }),
672            )?;
673            rt.log.append(EventDraft {
674                event_type: "attemptCreated".to_string(),
675                actor: WorkflowActor::Scheduler,
676                payload: serde_json::json!({
677                    "nodeId": node_id,
678                    "activityId": activity_id,
679                    "attemptId": attempt_id,
680                    "attemptNumber": 1,
681                    "inputRef": input_ref,
682                }),
683                timestamp: None,
684                payload_hash: None,
685            })?;
686
687            let snap = read_snapshot(rt).await?;
688            let bind_ctx = BindingContext {
689                snapshot: &snap,
690                def: &rt.def,
691                run_dir: &rt.log.run_dir,
692                loop_context: loop_context_from_activity(activity_id),
693            };
694
695            match node {
696                WorkflowNode::Subagent(subagent) => {
697                    let resolved_prompt = resolve_bound_string(&subagent.prompt, &bind_ctx).await?;
698                    rt.log.append(EventDraft {
699                        event_type: "activityRunning".to_string(),
700                        actor: WorkflowActor::Scheduler,
701                        payload: serde_json::json!({
702                            "activityId": activity_id,
703                            "attemptId": attempt_id,
704                            "leaseId": format!("lease-{}", attempt_id),
705                        }),
706                        timestamp: None,
707                        payload_hash: None,
708                    })?;
709                    let result = hooks
710                        .execute_subagent(
711                            WorkflowDispatchRun {
712                                run_id: &rt.log.run_id,
713                                workflow_id: snap.run.workflow_id.as_deref().unwrap_or(""),
714                                revision_id: snap.run.revision_id.as_deref().unwrap_or(""),
715                                activity_id,
716                                attempt_id: &attempt_id,
717                                node_id,
718                            },
719                            subagent,
720                            resolved_prompt,
721                        )
722                        .await?;
723                    settle_work_result(&mut rt.log, activity_id, &attempt_id, result).await
724                }
725                WorkflowNode::HostExecutor(executor) => {
726                    let resolved_input = resolve_bindings(&executor.input, &bind_ctx).await?;
727
728                    // --- prepare (parse + canonicalise) BEFORE any side-effect ---
729                    let prepared = hooks
730                        .prepare_host_executor(&executor.executor, &resolved_input)
731                        .context("prepare_host_executor failed")?;
732
733                    // --- write effect-input.json using the canonical input ---
734                    let _ = write_effect_input_sidecar(
735                        &rt.log,
736                        activity_id,
737                        &attempt_id,
738                        &prepared.canonical_input,
739                    )
740                    .await?;
741
742                    // --- emit effectAttempted BEFORE calling the external provider ---
743                    let idempotency_key = derive_workflow_idempotency_key(
744                        snap.run.workflow_id.as_deref().unwrap_or(""),
745                        snap.run.revision_id.as_deref().unwrap_or(""),
746                        &rt.log.run_id,
747                        node_id,
748                        &attempt_id,
749                    );
750                    let input_bytes = serde_json::to_vec(&prepared.canonical_input)?;
751                    let input_hash = sha256_hex(&input_bytes);
752                    rt.log.append(EventDraft {
753                        event_type: "effectAttempted".to_string(),
754                        actor: WorkflowActor::Scheduler,
755                        payload: serde_json::json!({
756                            "activityId": activity_id,
757                            "attemptId": attempt_id,
758                            "idempotencyKey": idempotency_key,
759                            "inputHash": input_hash,
760                            "idempotencyTtlMs": prepared.idempotency_ttl_ms,
761                            "provider": prepared.provider,
762                        }),
763                        timestamp: None,
764                        payload_hash: None,
765                    })?;
766
767                    let result = hooks
768                        .execute_host_executor(
769                            WorkflowDispatchRun {
770                                run_id: &rt.log.run_id,
771                                workflow_id: snap.run.workflow_id.as_deref().unwrap_or(""),
772                                revision_id: snap.run.revision_id.as_deref().unwrap_or(""),
773                                activity_id,
774                                attempt_id: &attempt_id,
775                                node_id,
776                            },
777                            executor,
778                            prepared.parsed_input,
779                        )
780                        .await?;
781                    settle_work_result(&mut rt.log, activity_id, &attempt_id, result).await
782                }
783                WorkflowNode::Loop(_) | WorkflowNode::Decision(_) => {
784                    anyhow::bail!("dispatch_work received unsupported node type")
785                }
786            }
787        }
788        _ => anyhow::bail!("dispatch_work called with non-work action"),
789    }
790}
791
792pub async fn complete_node_succeeded(
793    log: &mut EventLog,
794    action: &crate::OrchestratorAction,
795) -> Result<()> {
796    if let OrchestratorAction::CompleteNodeSucceeded {
797        node_id,
798        last_activity_id,
799        ..
800    } = action
801    {
802        let _ = log.append(EventDraft {
803            event_type: "nodeSucceeded".to_string(),
804            actor: WorkflowActor::Scheduler,
805            payload: serde_json::json!({
806                "nodeId": node_id,
807                "lastActivityId": last_activity_id,
808            }),
809            timestamp: None,
810            payload_hash: None,
811        })?;
812        Ok(())
813    } else {
814        anyhow::bail!("complete_node_succeeded called with wrong action")
815    }
816}
817
818pub async fn complete_node_failed(
819    log: &mut EventLog,
820    action: &crate::OrchestratorAction,
821) -> Result<()> {
822    if let OrchestratorAction::CompleteNodeFailed {
823        node_id,
824        last_activity_id,
825        error_class,
826    } = action
827    {
828        let _ = log.append(EventDraft {
829            event_type: "nodeFailed".to_string(),
830            actor: WorkflowActor::Scheduler,
831            payload: serde_json::json!({
832                "nodeId": node_id,
833                "lastActivityId": last_activity_id,
834                "errorClass": error_class,
835            }),
836            timestamp: None,
837            payload_hash: None,
838        })?;
839        Ok(())
840    } else {
841        anyhow::bail!("complete_node_failed called with wrong action")
842    }
843}
844
845pub async fn complete_run_succeeded(
846    log: &mut EventLog,
847    action: &crate::OrchestratorAction,
848) -> Result<()> {
849    if let OrchestratorAction::CompleteRunSucceeded { output_ref, .. } = action {
850        let _ = log.append(EventDraft {
851            event_type: "runSucceeded".to_string(),
852            actor: WorkflowActor::Scheduler,
853            payload: serde_json::json!({
854                "outputRef": output_ref,
855            }),
856            timestamp: None,
857            payload_hash: None,
858        })?;
859        Ok(())
860    } else {
861        anyhow::bail!("complete_run_succeeded called with wrong action")
862    }
863}
864
865pub async fn complete_run_failed(
866    log: &mut EventLog,
867    action: &crate::OrchestratorAction,
868) -> Result<()> {
869    if let OrchestratorAction::CompleteRunFailed { failed_node_id } = action {
870        let root_cause_event_id = find_root_cause_event_id(log, failed_node_id).await?;
871        let _ = log.append(EventDraft {
872            event_type: "runFailed".to_string(),
873            actor: WorkflowActor::Scheduler,
874            payload: serde_json::json!({
875                "failedNodeId": failed_node_id,
876                "rootCauseEventId": root_cause_event_id,
877            }),
878            timestamp: None,
879            payload_hash: None,
880        })?;
881        Ok(())
882    } else {
883        anyhow::bail!("complete_run_failed called with wrong action")
884    }
885}
886
887pub async fn start_loop(log: &mut EventLog, action: &crate::OrchestratorAction) -> Result<()> {
888    if let OrchestratorAction::StartLoop {
889        node_id,
890        max_iterations,
891    } = action
892    {
893        let _ = log.append(EventDraft {
894            event_type: "loopStarted".to_string(),
895            actor: WorkflowActor::Scheduler,
896            payload: serde_json::json!({
897                "loopId": node_id,
898                "maxIterations": max_iterations,
899            }),
900            timestamp: None,
901            payload_hash: None,
902        })?;
903        Ok(())
904    } else {
905        anyhow::bail!("start_loop called with wrong action")
906    }
907}
908
909pub async fn start_loop_iteration(
910    log: &mut EventLog,
911    action: &crate::OrchestratorAction,
912) -> Result<()> {
913    if let OrchestratorAction::StartLoopIteration { node_id, iteration } = action {
914        let _ = log.append(EventDraft {
915            event_type: "loopIterationStarted".to_string(),
916            actor: WorkflowActor::Scheduler,
917            payload: serde_json::json!({
918                "loopId": node_id,
919                "iteration": iteration,
920            }),
921            timestamp: None,
922            payload_hash: None,
923        })?;
924        Ok(())
925    } else {
926        anyhow::bail!("start_loop_iteration called with wrong action")
927    }
928}
929
930pub async fn finish_loop_iteration(
931    log: &mut EventLog,
932    action: &crate::OrchestratorAction,
933) -> Result<()> {
934    if let OrchestratorAction::FinishLoopIteration {
935        node_id,
936        iteration,
937        resolution,
938        decision_activity_id,
939        wait_resolved_event_id,
940        by,
941        comment,
942        timed_out,
943    } = action
944    {
945        let _ = log.append(EventDraft {
946            event_type: "loopIterationFinished".to_string(),
947            actor: WorkflowActor::Scheduler,
948            payload: serde_json::json!({
949                "loopId": node_id,
950                "iteration": iteration,
951                "resolution": resolution,
952                "decisionActivityId": decision_activity_id,
953                "waitResolvedEventId": wait_resolved_event_id,
954                "by": by,
955                "comment": comment,
956                "timedOut": timed_out,
957            }),
958            timestamp: None,
959            payload_hash: None,
960        })?;
961        Ok(())
962    } else {
963        anyhow::bail!("finish_loop_iteration called with wrong action")
964    }
965}
966
967pub async fn finish_loop(log: &mut EventLog, action: &crate::OrchestratorAction) -> Result<()> {
968    if let OrchestratorAction::FinishLoop {
969        node_id,
970        final_iteration,
971        resolution,
972        output_ref,
973        error_code,
974        error_class,
975    } = action
976    {
977        let _ = log.append(EventDraft {
978            event_type: "loopFinished".to_string(),
979            actor: WorkflowActor::Scheduler,
980            payload: serde_json::json!({
981                "loopId": node_id,
982                "finalIteration": final_iteration,
983                "resolution": resolution,
984                "outputRef": output_ref,
985                "errorCode": error_code,
986                "errorClass": error_class,
987            }),
988            timestamp: None,
989            payload_hash: None,
990        })?;
991        Ok(())
992    } else {
993        anyhow::bail!("finish_loop called with wrong action")
994    }
995}
996
997async fn apply_orchestrator_action<H: WorkflowExecutionHooks>(
998    rt: &mut WorkflowRuntimeContext,
999    hooks: &mut H,
1000    action: OrchestratorAction,
1001) -> Result<()> {
1002    match action {
1003        OrchestratorAction::DispatchGate { .. } => dispatch_gate(rt, &action).await?,
1004        OrchestratorAction::DispatchWork { .. } => {
1005            let _ = dispatch_work(rt, hooks, &action).await?;
1006        }
1007        OrchestratorAction::CompleteNodeSucceeded { .. } => {
1008            complete_node_succeeded(&mut rt.log, &action).await?
1009        }
1010        OrchestratorAction::CompleteNodeFailed { .. } => {
1011            complete_node_failed(&mut rt.log, &action).await?
1012        }
1013        OrchestratorAction::CompleteRunSucceeded { .. } => {
1014            complete_run_succeeded(&mut rt.log, &action).await?
1015        }
1016        OrchestratorAction::CompleteRunFailed { .. } => {
1017            complete_run_failed(&mut rt.log, &action).await?
1018        }
1019        OrchestratorAction::StartLoop { .. } => start_loop(&mut rt.log, &action).await?,
1020        OrchestratorAction::StartLoopIteration { .. } => {
1021            start_loop_iteration(&mut rt.log, &action).await?
1022        }
1023        OrchestratorAction::FinishLoopIteration { .. } => {
1024            finish_loop_iteration(&mut rt.log, &action).await?
1025        }
1026        OrchestratorAction::FinishLoop { .. } => finish_loop(&mut rt.log, &action).await?,
1027    }
1028    Ok(())
1029}
1030
1031async fn settle_work_result(
1032    log: &mut EventLog,
1033    activity_id: &str,
1034    attempt_id: &str,
1035    result: WorkflowDispatchOutcome,
1036) -> Result<WorkflowDispatchOutcome> {
1037    match &result {
1038        WorkflowDispatchOutcome::Succeeded { output, .. } => {
1039            let output_ref = write_json_blob(log, output.clone())?;
1040            let _ = log.append(EventDraft {
1041                event_type: "activitySucceeded".to_string(),
1042                actor: WorkflowActor::Worker,
1043                payload: serde_json::json!({
1044                    "activityId": activity_id,
1045                    "attemptId": attempt_id,
1046                    "outputRef": output_ref,
1047                }),
1048                timestamp: None,
1049                payload_hash: None,
1050            })?;
1051        }
1052        WorkflowDispatchOutcome::Failed {
1053            error_code,
1054            error_class,
1055            error_message,
1056            ..
1057        } => {
1058            let _ = log.append(EventDraft {
1059                event_type: "activityFailed".to_string(),
1060                actor: WorkflowActor::Worker,
1061                payload: serde_json::json!({
1062                    "activityId": activity_id,
1063                    "attemptId": attempt_id,
1064                    "error": {
1065                        "errorCode": error_code,
1066                        "errorClass": error_class,
1067                        "errorMessage": error_message,
1068                    }
1069                }),
1070                timestamp: None,
1071                payload_hash: None,
1072            })?;
1073        }
1074        WorkflowDispatchOutcome::Cancelled {
1075            cancel_origin_event_id,
1076            ..
1077        } => {
1078            let _ = log.append(EventDraft {
1079                event_type: "activityCanceled".to_string(),
1080                actor: WorkflowActor::Worker,
1081                payload: serde_json::json!({
1082                    "activityId": activity_id,
1083                    "attemptId": attempt_id,
1084                    "cancelOriginEventId": cancel_origin_event_id,
1085                }),
1086                timestamp: None,
1087                payload_hash: None,
1088            })?;
1089        }
1090    }
1091    Ok(result)
1092}
1093
1094async fn find_root_cause_event_id(log: &EventLog, node_id: &str) -> Result<String> {
1095    let events = log.read_all()?;
1096    let mut node_failed_event_id: Option<String> = None;
1097    let mut activity_failed_event_id: Option<String> = None;
1098    let mut loop_finished_event_id: Option<String> = None;
1099    let mut node_activities = std::collections::BTreeSet::new();
1100    for e in &events {
1101        match e.event_type.as_str() {
1102            "attemptCreated" => {
1103                if e.payload.get("nodeId").and_then(Value::as_str) == Some(node_id)
1104                    && let Some(activity_id) = e.payload.get("activityId").and_then(Value::as_str)
1105                {
1106                    node_activities.insert(activity_id.to_string());
1107                }
1108            }
1109            "activityFailed" => {
1110                if let Some(activity_id) = e.payload.get("activityId").and_then(Value::as_str)
1111                    && node_activities.contains(activity_id)
1112                {
1113                    activity_failed_event_id = Some(e.event_id.clone());
1114                }
1115            }
1116            "nodeFailed" => {
1117                if e.payload.get("nodeId").and_then(Value::as_str) == Some(node_id) {
1118                    node_failed_event_id = Some(e.event_id.clone());
1119                }
1120            }
1121            "loopFinished" => {
1122                if e.payload.get("loopId").and_then(Value::as_str) == Some(node_id)
1123                    && e.payload.get("resolution").and_then(Value::as_str) != Some("approved")
1124                {
1125                    loop_finished_event_id = Some(e.event_id.clone());
1126                }
1127            }
1128            _ => {}
1129        }
1130    }
1131    Ok(activity_failed_event_id
1132        .or(node_failed_event_id)
1133        .or(loop_finished_event_id)
1134        .unwrap_or_else(|| {
1135            events
1136                .first()
1137                .map(|e| e.event_id.clone())
1138                .unwrap_or_default()
1139        }))
1140}
1141
1142fn gate_attempt_id(activity_id: &str) -> String {
1143    format!("{activity_id}::att-1")
1144}
1145
1146fn work_attempt_id(activity_id: &str, attempt_number: u64) -> String {
1147    format!("{activity_id}::att-{attempt_number}")
1148}
1149
1150fn now_ms() -> u64 {
1151    std::time::SystemTime::now()
1152        .duration_since(std::time::UNIX_EPOCH)
1153        .unwrap_or_default()
1154        .as_millis() as u64
1155}
1156
1157fn split_prompt(log: &mut EventLog, resolved_prompt: &str) -> Result<PromptField> {
1158    if resolved_prompt.len() <= 1024 {
1159        return Ok(PromptField {
1160            prompt: Some(resolved_prompt.to_string()),
1161            prompt_ref: None,
1162            prompt_preview: None,
1163        });
1164    }
1165    let prompt_ref = write_json_blob(log, Value::String(resolved_prompt.to_string()))?;
1166    Ok(PromptField {
1167        prompt: None,
1168        prompt_ref: Some(prompt_ref),
1169        prompt_preview: Some(make_prompt_preview(resolved_prompt)),
1170    })
1171}
1172
1173#[derive(Debug)]
1174struct PromptField {
1175    prompt: Option<String>,
1176    prompt_ref: Option<WorkflowOutputRef>,
1177    prompt_preview: Option<String>,
1178}
1179
1180fn make_prompt_preview(full: &str) -> String {
1181    const MAX: usize = 480;
1182    if full.chars().count() <= MAX {
1183        return full.to_string();
1184    }
1185    let suffix = "…(完整内容见 dashboard)";
1186    let budget = MAX.saturating_sub(suffix.chars().count());
1187    let mut out = String::new();
1188    for ch in full.chars().take(budget) {
1189        out.push(ch);
1190    }
1191    out.push_str(suffix);
1192    out
1193}
1194
1195fn write_json_blob(log: &mut EventLog, value: Value) -> Result<WorkflowOutputRef> {
1196    let bytes = serde_json::to_vec(&value)?;
1197    let hash = sha256_hex(&bytes);
1198    let path = PathBuf::from(&log.blob_dir).join(&hash);
1199    fs::write(&path, &bytes)?;
1200    Ok(WorkflowOutputRef {
1201        output_hash: format!("sha256:{hash}"),
1202        output_path: path.display().to_string(),
1203        output_bytes: bytes.len(),
1204        output_schema_version: 1,
1205        content_type: Some("application/json".to_string()),
1206    })
1207}
1208
1209fn sha256_hex(bytes: &[u8]) -> String {
1210    let mut hasher = Sha256::new();
1211    hasher.update(bytes);
1212    format!("{:x}", hasher.finalize())
1213}
1214
1215/// Derive a deterministic idempotency key for a workflow host-executor attempt.
1216///
1217/// The key is `wf_` prefixed with a SHA-256 hex fragment of the canonical
1218/// (workflowId, revisionId, runId, nodeId, attemptId) seed.  This function
1219/// lives in `beam-core` so the runtime can emit `effectAttempted` events
1220/// before delegating to the daemon’s executor hooks.
1221pub fn derive_workflow_idempotency_key(
1222    workflow_id: &str,
1223    revision_id: &str,
1224    run_id: &str,
1225    node_id: &str,
1226    attempt_id: &str,
1227) -> String {
1228    let seed = serde_json::json!({
1229        "attemptId": attempt_id,
1230        "nodeId": node_id,
1231        "revisionId": revision_id,
1232        "runId": run_id,
1233        "workflowId": workflow_id,
1234    });
1235    let mut hasher = Sha256::new();
1236    let canonical = serde_json::to_vec(&seed).expect("workflow idempotency seed serializable");
1237    hasher.update(&canonical);
1238    let hash = format!("{:x}", hasher.finalize());
1239    let namespace = "wf_";
1240    let max_len = 50usize;
1241    let hash_len = max_len.saturating_sub(namespace.len());
1242    format!("{namespace}{}", &hash[..hash_len.min(hash.len())])
1243}
1244
1245/// Return (provider, idempotency_ttl_ms) metadata for a known host executor.
1246///
1247/// This mapping lives in `beam-core` so that `effectAttempted` events can be
1248/// emitted with accurate provider / TTL information without depending on the
1249/// daemon’s HostExecutor trait.
1250pub fn get_host_executor_provider_meta(executor_name: &str) -> (&'static str, u64) {
1251    match executor_name {
1252        "feishu-send" | "feishu-reply" => ("feishu-im", 60_000),
1253        "beam-schedule" => ("beam-schedule", 86_400_000),
1254        _ => ("manual", 300_000),
1255    }
1256}
1257
1258fn loop_context_from_activity(activity_id: &str) -> Option<LoopContext<'_>> {
1259    let loop_start = activity_id.find("::loop::")?;
1260    let after_loop = &activity_id[loop_start + "::loop::".len()..];
1261    let iter_end = after_loop.find("::")?;
1262    let loop_part = &after_loop[..iter_end];
1263    let (loop_id, iteration) = loop_part.rsplit_once('.')?;
1264    let iteration = iteration.parse().ok()?;
1265    Some(LoopContext { loop_id, iteration })
1266}
1267
1268/// Materialise terminal events (activitySucceeded / activityFailed) for
1269/// activities that have a recorded wait resolution (waitResolved /
1270/// waitDeadlineExceeded) but are missing the terminal activity event.
1271///
1272/// This handles the dangling wait resolution case where the daemon crashed
1273/// after writing the wait resolution but before writing the terminal event.
1274/// Returns true if at least one terminal event was written.
1275async fn resolve_wait_terminals(
1276    rt: &mut WorkflowRuntimeContext,
1277    snapshot: &RunSnapshotDTO,
1278) -> Result<bool> {
1279    let mut had_progress = false;
1280    for activity_id in &snapshot.dangling.wait_resolutions {
1281        let Some(activity) = snapshot
1282            .activities
1283            .iter()
1284            .find(|a| &a.activity_id == activity_id)
1285        else {
1286            continue;
1287        };
1288        let Some(latest) = activity.attempts.last() else {
1289            continue;
1290        };
1291        let Some(wait) = latest.wait.as_ref() else {
1292            continue;
1293        };
1294        let Some(resolution) = wait.resolution.as_ref() else {
1295            continue;
1296        };
1297        let attempt_id = &latest.attempt_id;
1298
1299        match resolution.kind.as_str() {
1300            "resolved" => {
1301                if matches!(resolution.resolution.as_deref(), Some("rejected")) {
1302                    // reject → activityFailed (non-decision nodes)
1303                    rt.log.append(EventDraft {
1304                        event_type: "activityFailed".to_string(),
1305                        actor: WorkflowActor::Scheduler,
1306                        payload: serde_json::json!({
1307                            "activityId": activity_id,
1308                            "attemptId": attempt_id,
1309                            "error": {
1310                                "errorCode": "InputValidationFailed",
1311                                "errorClass": "userFault",
1312                                "errorMessage": format!(
1313                                    "Recovered wait terminal: rejected by {}{}",
1314                                    resolution.by.clone().unwrap_or_default(),
1315                                    resolution.comment.as_ref()
1316                                        .map(|c| format!(": {}", c))
1317                                        .unwrap_or_default()
1318                                ),
1319                            }
1320                        }),
1321                        timestamp: None,
1322                        payload_hash: None,
1323                    })?;
1324                    had_progress = true;
1325                } else {
1326                    // approved / external → activitySucceeded
1327                    let external_refs = serde_json::json!({
1328                        "resolution": resolution.resolution,
1329                        "by": resolution.by,
1330                        "comment": resolution.comment,
1331                    });
1332                    let output_ref = write_json_blob(&mut rt.log, external_refs.clone())?;
1333                    rt.log.append(EventDraft {
1334                        event_type: "activitySucceeded".to_string(),
1335                        actor: WorkflowActor::Scheduler,
1336                        payload: serde_json::json!({
1337                            "activityId": activity_id,
1338                            "attemptId": attempt_id,
1339                            "outputRef": output_ref,
1340                            "externalRefs": external_refs,
1341                        }),
1342                        timestamp: None,
1343                        payload_hash: None,
1344                    })?;
1345                    had_progress = true;
1346                }
1347            }
1348            "deadlineExceeded" => {
1349                if matches!(wait.on_timeout.as_deref(), Some("success")) {
1350                    let external_refs = serde_json::json!({
1351                        "defaultedToTimeout": true,
1352                        "deadlineAt": resolution.deadline_at,
1353                    });
1354                    let output_ref = write_json_blob(&mut rt.log, external_refs.clone())?;
1355                    rt.log.append(EventDraft {
1356                        event_type: "activitySucceeded".to_string(),
1357                        actor: WorkflowActor::Scheduler,
1358                        payload: serde_json::json!({
1359                            "activityId": activity_id,
1360                            "attemptId": attempt_id,
1361                            "outputRef": output_ref,
1362                            "externalRefs": external_refs,
1363                        }),
1364                        timestamp: None,
1365                        payload_hash: None,
1366                    })?;
1367                    had_progress = true;
1368                } else {
1369                    // fail (default) → activityFailed
1370                    rt.log.append(EventDraft {
1371                        event_type: "activityFailed".to_string(),
1372                        actor: WorkflowActor::Scheduler,
1373                        payload: serde_json::json!({
1374                            "activityId": activity_id,
1375                            "attemptId": attempt_id,
1376                            "error": {
1377                                "errorCode": "WaitDeadlineExceeded",
1378                                "errorClass": "userFault",
1379                                "errorMessage": "Recovered wait terminal: deadline exceeded",
1380                            }
1381                        }),
1382                        timestamp: None,
1383                        payload_hash: None,
1384                    })?;
1385                    had_progress = true;
1386                }
1387            }
1388            _ => {}
1389        }
1390    }
1391    Ok(had_progress)
1392}
1393
1394async fn read_snapshot(rt: &WorkflowRuntimeContext) -> Result<RunSnapshotDTO> {
1395    read_run_snapshot(&rt.log.run_dir)
1396        .await?
1397        .context("workflow runtime requires an existing run snapshot")
1398}
1399
1400#[cfg(test)]
1401mod tests {
1402    use super::*;
1403    use crate::DanglingSnapshot;
1404    use crate::LoopIterationStatus;
1405    use crate::LoopStatus;
1406    use crate::RunChatBinding;
1407    use crate::RunState;
1408    use crate::workflow_definition::NodeBase;
1409    use crate::workflow_definition::{
1410        DecisionNode, HumanGate, LoopNode, LoopOutputProjection, LoopTerminate,
1411    };
1412    use crate::workflow_snapshot::NodeStatus;
1413    use std::collections::{BTreeMap, HashMap};
1414    use std::sync::Arc;
1415    use tokio::sync::Mutex;
1416
1417    #[derive(Clone)]
1418    struct FakeHooks;
1419
1420    #[async_trait]
1421    impl WorkflowExecutionHooks for FakeHooks {
1422        async fn execute_subagent(
1423            &mut self,
1424            _ctx: WorkflowDispatchRun<'_>,
1425            _node: &SubagentNode,
1426            resolved_prompt: String,
1427        ) -> Result<WorkflowDispatchOutcome> {
1428            Ok(WorkflowDispatchOutcome::Succeeded {
1429                output: Value::String(resolved_prompt),
1430                session: None,
1431            })
1432        }
1433
1434        async fn execute_host_executor(
1435            &mut self,
1436            _ctx: WorkflowDispatchRun<'_>,
1437            _node: &HostExecutorNode,
1438            resolved_input: Value,
1439        ) -> Result<WorkflowDispatchOutcome> {
1440            Ok(WorkflowDispatchOutcome::Succeeded {
1441                output: resolved_input,
1442                session: None,
1443            })
1444        }
1445    }
1446
1447    fn temp_run_dir(label: &str) -> PathBuf {
1448        std::env::temp_dir().join(format!(
1449            "beam-workflow-runtime-{label}-{}-{}",
1450            std::process::id(),
1451            std::time::SystemTime::now()
1452                .duration_since(std::time::UNIX_EPOCH)
1453                .unwrap_or_default()
1454                .as_nanos()
1455        ))
1456    }
1457
1458    fn workflow_def() -> WorkflowDefinition {
1459        WorkflowDefinition {
1460            workflow_id: "flow-a".to_string(),
1461            version: 1,
1462            params: None,
1463            defaults: None,
1464            nodes: BTreeMap::from([(
1465                "a".to_string(),
1466                WorkflowNode::Subagent(SubagentNode {
1467                    base: NodeBase {
1468                        description: None,
1469                        depends: None,
1470                        human_gate: None,
1471                        retry_policy: None,
1472                        timeout_ms: None,
1473                        max_output_bytes: None,
1474                        output_schema: None,
1475                        unsafe_allow_ungated: None,
1476                    },
1477                    bot: "bot-a".to_string(),
1478                    prompt: Value::String("hello ${params.name}".to_string()),
1479                    working_dir: None,
1480                    model_overrides: None,
1481                    tool_policy: None,
1482                }),
1483            )]),
1484        }
1485    }
1486
1487    #[tokio::test]
1488    async fn run_tick_dispatches_simple_workflow() {
1489        let run_dir = temp_run_dir("tick");
1490        let _ = fs::remove_dir_all(&run_dir);
1491        fs::create_dir_all(run_dir.join("blobs")).unwrap();
1492        fs::write(run_dir.join("params.json"), r#"{"name":"beam"}"#).unwrap();
1493
1494        let paths = crate::BeamPaths::from_root(run_dir.clone());
1495        let params: BTreeMap<String, Value> =
1496            BTreeMap::from([(String::from("name"), Value::String("beam".to_string()))]);
1497        let run_id = "run-1";
1498        let bootstrap = crate::bootstrap_workflow_run(
1499            &paths,
1500            crate::BootstrapWorkflowRunInput {
1501                run_id,
1502                workflow_json: r#"{"workflowId":"flow-a","version":1,"params":{"name":{"type":"string"}},"nodes":{"a":{"type":"subagent","bot":"bot-a","prompt":"hello ${params.name}"}}}"#,
1503                expected_workflow_id: Some("flow-a"),
1504                params: &params,
1505                initiator: "cli",
1506                chat_binding: Some(RunChatBinding {
1507                    chat_id: "chat-1".to_string(),
1508                    lark_app_id: "app-1".to_string(),
1509                }),
1510            },
1511        )
1512        .unwrap();
1513        let mut rt = WorkflowRuntimeContext {
1514            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
1515            def: workflow_def(),
1516            runs_base_dir: paths.workflow_runs_dir(),
1517        };
1518        let mut hooks = FakeHooks;
1519        let tick = run_tick(&mut rt, &mut hooks, 1).await.unwrap();
1520        assert!(tick.actions > 0);
1521        assert_eq!(tick.snapshot.run.workflow_id.as_deref(), Some("flow-a"));
1522        assert!(matches!(
1523            tick.snapshot
1524                .nodes
1525                .iter()
1526                .find(|node| node.node_id == "a")
1527                .map(|node| node.status),
1528            Some(NodeStatus::Succeeded | NodeStatus::Running | NodeStatus::Triggered)
1529        ));
1530        let _ = fs::remove_dir_all(&run_dir);
1531        let _ = bootstrap;
1532    }
1533
1534    #[tokio::test]
1535    async fn run_tick_honors_max_concurrency_cap() {
1536        let run_dir = temp_run_dir("cap");
1537        let _ = fs::remove_dir_all(&run_dir);
1538        fs::create_dir_all(run_dir.join("blobs")).unwrap();
1539        let paths = crate::BeamPaths::from_root(run_dir.clone());
1540        let params: BTreeMap<String, Value> = BTreeMap::new();
1541        let run_id = "run-cap";
1542        crate::bootstrap_workflow_run(
1543            &paths,
1544            crate::BootstrapWorkflowRunInput {
1545                run_id,
1546                workflow_json: r#"{"workflowId":"flow-cap","version":1,"nodes":{"a":{"type":"subagent","bot":"bot-a","prompt":"one"},"b":{"type":"subagent","bot":"bot-b","prompt":"two"}}}"#,
1547                expected_workflow_id: Some("flow-cap"),
1548                params: &params,
1549                initiator: "cli",
1550                chat_binding: Some(RunChatBinding {
1551                    chat_id: "chat-1".to_string(),
1552                    lark_app_id: "app-1".to_string(),
1553                }),
1554            },
1555        )
1556        .unwrap();
1557        let mut rt = WorkflowRuntimeContext {
1558            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
1559            def: WorkflowDefinition {
1560                workflow_id: "flow-cap".to_string(),
1561                version: 1,
1562                params: None,
1563                defaults: None,
1564                nodes: BTreeMap::from([
1565                    (
1566                        "a".to_string(),
1567                        WorkflowNode::Subagent(SubagentNode {
1568                            base: NodeBase {
1569                                description: None,
1570                                depends: None,
1571                                human_gate: None,
1572                                retry_policy: None,
1573                                timeout_ms: None,
1574                                max_output_bytes: None,
1575                                output_schema: None,
1576                                unsafe_allow_ungated: None,
1577                            },
1578                            bot: "bot-a".to_string(),
1579                            prompt: Value::String("one".to_string()),
1580                            working_dir: None,
1581                            model_overrides: None,
1582                            tool_policy: None,
1583                        }),
1584                    ),
1585                    (
1586                        "b".to_string(),
1587                        WorkflowNode::Subagent(SubagentNode {
1588                            base: NodeBase {
1589                                description: None,
1590                                depends: None,
1591                                human_gate: None,
1592                                retry_policy: None,
1593                                timeout_ms: None,
1594                                max_output_bytes: None,
1595                                output_schema: None,
1596                                unsafe_allow_ungated: None,
1597                            },
1598                            bot: "bot-b".to_string(),
1599                            prompt: Value::String("two".to_string()),
1600                            working_dir: None,
1601                            model_overrides: None,
1602                            tool_policy: None,
1603                        }),
1604                    ),
1605                ]),
1606            },
1607            runs_base_dir: paths.workflow_runs_dir(),
1608        };
1609        let mut hooks = FakeHooks;
1610        let tick = run_tick(&mut rt, &mut hooks, 1).await.unwrap();
1611        assert_eq!(tick.actions, 1);
1612        let snapshot = tick.snapshot;
1613        let attempted: Vec<_> = snapshot
1614            .activities
1615            .iter()
1616            .map(|a| a.activity_id.as_str())
1617            .collect();
1618        assert_eq!(attempted.len(), 1);
1619        let _ = fs::remove_dir_all(&run_dir);
1620    }
1621
1622    #[tokio::test]
1623    async fn run_tick_dispatches_multiple_actions_concurrently() {
1624        let run_dir = temp_run_dir("concurrent");
1625        let _ = fs::remove_dir_all(&run_dir);
1626        fs::create_dir_all(run_dir.join("blobs")).unwrap();
1627        let paths = crate::BeamPaths::from_root(run_dir.clone());
1628        let params: BTreeMap<String, Value> = BTreeMap::new();
1629        let run_id = "run-concurrent";
1630        crate::bootstrap_workflow_run(
1631            &paths,
1632            crate::BootstrapWorkflowRunInput {
1633                run_id,
1634                workflow_json: r#"{"workflowId":"flow-concurrent","version":1,"nodes":{"a":{"type":"subagent","bot":"bot-a","prompt":"one"},"b":{"type":"subagent","bot":"bot-b","prompt":"two"}}}"#,
1635                expected_workflow_id: Some("flow-concurrent"),
1636                params: &params,
1637                initiator: "cli",
1638                chat_binding: Some(RunChatBinding {
1639                    chat_id: "chat-1".to_string(),
1640                    lark_app_id: "app-1".to_string(),
1641                }),
1642            },
1643        )
1644        .unwrap();
1645        let mut rt = WorkflowRuntimeContext {
1646            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
1647            def: WorkflowDefinition {
1648                workflow_id: "flow-concurrent".to_string(),
1649                version: 1,
1650                params: None,
1651                defaults: None,
1652                nodes: BTreeMap::from([
1653                    (
1654                        "a".to_string(),
1655                        WorkflowNode::Subagent(SubagentNode {
1656                            base: NodeBase {
1657                                description: None,
1658                                depends: None,
1659                                human_gate: None,
1660                                retry_policy: None,
1661                                timeout_ms: None,
1662                                max_output_bytes: None,
1663                                output_schema: None,
1664                                unsafe_allow_ungated: None,
1665                            },
1666                            bot: "bot-a".to_string(),
1667                            prompt: Value::String("one".to_string()),
1668                            working_dir: None,
1669                            model_overrides: None,
1670                            tool_policy: None,
1671                        }),
1672                    ),
1673                    (
1674                        "b".to_string(),
1675                        WorkflowNode::Subagent(SubagentNode {
1676                            base: NodeBase {
1677                                description: None,
1678                                depends: None,
1679                                human_gate: None,
1680                                retry_policy: None,
1681                                timeout_ms: None,
1682                                max_output_bytes: None,
1683                                output_schema: None,
1684                                unsafe_allow_ungated: None,
1685                            },
1686                            bot: "bot-b".to_string(),
1687                            prompt: Value::String("two".to_string()),
1688                            working_dir: None,
1689                            model_overrides: None,
1690                            tool_policy: None,
1691                        }),
1692                    ),
1693                ]),
1694            },
1695            runs_base_dir: paths.workflow_runs_dir(),
1696        };
1697
1698        #[derive(Clone)]
1699        struct SleepyHooks;
1700
1701        #[async_trait]
1702        impl WorkflowExecutionHooks for SleepyHooks {
1703            async fn execute_subagent(
1704                &mut self,
1705                _ctx: WorkflowDispatchRun<'_>,
1706                _node: &SubagentNode,
1707                resolved_prompt: String,
1708            ) -> Result<WorkflowDispatchOutcome> {
1709                tokio::time::sleep(std::time::Duration::from_millis(120)).await;
1710                Ok(WorkflowDispatchOutcome::Succeeded {
1711                    output: Value::String(resolved_prompt),
1712                    session: None,
1713                })
1714            }
1715
1716            async fn execute_host_executor(
1717                &mut self,
1718                _ctx: WorkflowDispatchRun<'_>,
1719                _node: &HostExecutorNode,
1720                resolved_input: Value,
1721            ) -> Result<WorkflowDispatchOutcome> {
1722                Ok(WorkflowDispatchOutcome::Succeeded {
1723                    output: resolved_input,
1724                    session: None,
1725                })
1726            }
1727        }
1728
1729        let mut hooks = SleepyHooks;
1730        let started = std::time::Instant::now();
1731        let tick = run_tick(&mut rt, &mut hooks, 2).await.unwrap();
1732        let elapsed = started.elapsed();
1733        assert_eq!(tick.actions, 2);
1734        assert!(
1735            elapsed < std::time::Duration::from_millis(220),
1736            "run_tick took {:?}, expected concurrent execution under 220ms",
1737            elapsed
1738        );
1739        let _ = fs::remove_dir_all(&run_dir);
1740    }
1741
1742    #[tokio::test]
1743    async fn run_tick_serializes_actions_for_the_same_bot() {
1744        let run_dir = temp_run_dir("same-bot");
1745        let _ = fs::remove_dir_all(&run_dir);
1746        fs::create_dir_all(run_dir.join("blobs")).unwrap();
1747        let paths = crate::BeamPaths::from_root(run_dir.clone());
1748        let params: BTreeMap<String, Value> = BTreeMap::new();
1749        let run_id = "run-same-bot";
1750        crate::bootstrap_workflow_run(
1751            &paths,
1752            crate::BootstrapWorkflowRunInput {
1753                run_id,
1754                workflow_json: r#"{"workflowId":"flow-same-bot","version":1,"nodes":{"a":{"type":"subagent","bot":"bot-shared","prompt":"one"},"b":{"type":"subagent","bot":"bot-shared","prompt":"two"},"c":{"type":"subagent","bot":"bot-other","prompt":"three"}}}"#,
1755                expected_workflow_id: Some("flow-same-bot"),
1756                params: &params,
1757                initiator: "cli",
1758                chat_binding: Some(RunChatBinding {
1759                    chat_id: "chat-1".to_string(),
1760                    lark_app_id: "app-1".to_string(),
1761                }),
1762            },
1763        )
1764        .unwrap();
1765        let mut rt = WorkflowRuntimeContext {
1766            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
1767            def: WorkflowDefinition {
1768                workflow_id: "flow-same-bot".to_string(),
1769                version: 1,
1770                params: None,
1771                defaults: None,
1772                nodes: BTreeMap::from([
1773                    (
1774                        "a".to_string(),
1775                        WorkflowNode::Subagent(SubagentNode {
1776                            base: NodeBase {
1777                                description: None,
1778                                depends: None,
1779                                human_gate: None,
1780                                retry_policy: None,
1781                                timeout_ms: None,
1782                                max_output_bytes: None,
1783                                output_schema: None,
1784                                unsafe_allow_ungated: None,
1785                            },
1786                            bot: "bot-shared".to_string(),
1787                            prompt: Value::String("one".to_string()),
1788                            working_dir: None,
1789                            model_overrides: None,
1790                            tool_policy: None,
1791                        }),
1792                    ),
1793                    (
1794                        "b".to_string(),
1795                        WorkflowNode::Subagent(SubagentNode {
1796                            base: NodeBase {
1797                                description: None,
1798                                depends: None,
1799                                human_gate: None,
1800                                retry_policy: None,
1801                                timeout_ms: None,
1802                                max_output_bytes: None,
1803                                output_schema: None,
1804                                unsafe_allow_ungated: None,
1805                            },
1806                            bot: "bot-shared".to_string(),
1807                            prompt: Value::String("two".to_string()),
1808                            working_dir: None,
1809                            model_overrides: None,
1810                            tool_policy: None,
1811                        }),
1812                    ),
1813                    (
1814                        "c".to_string(),
1815                        WorkflowNode::Subagent(SubagentNode {
1816                            base: NodeBase {
1817                                description: None,
1818                                depends: None,
1819                                human_gate: None,
1820                                retry_policy: None,
1821                                timeout_ms: None,
1822                                max_output_bytes: None,
1823                                output_schema: None,
1824                                unsafe_allow_ungated: None,
1825                            },
1826                            bot: "bot-other".to_string(),
1827                            prompt: Value::String("three".to_string()),
1828                            working_dir: None,
1829                            model_overrides: None,
1830                            tool_policy: None,
1831                        }),
1832                    ),
1833                ]),
1834            },
1835            runs_base_dir: paths.workflow_runs_dir(),
1836        };
1837
1838        #[derive(Clone)]
1839        struct SerializingHooks {
1840            started: Arc<Mutex<Vec<String>>>,
1841            active_by_bot: Arc<Mutex<HashMap<String, usize>>>,
1842            max_active_by_bot: Arc<Mutex<HashMap<String, usize>>>,
1843        }
1844
1845        #[async_trait]
1846        impl WorkflowExecutionHooks for SerializingHooks {
1847            async fn execute_subagent(
1848                &mut self,
1849                _ctx: WorkflowDispatchRun<'_>,
1850                node: &SubagentNode,
1851                resolved_prompt: String,
1852            ) -> Result<WorkflowDispatchOutcome> {
1853                let bot = node.bot.clone();
1854                {
1855                    let mut active = self.active_by_bot.lock().await;
1856                    let entry = active.entry(bot.clone()).or_insert(0);
1857                    *entry += 1;
1858                    let mut max_active = self.max_active_by_bot.lock().await;
1859                    let max_entry = max_active.entry(bot.clone()).or_insert(0);
1860                    if *entry > *max_entry {
1861                        *max_entry = *entry;
1862                    }
1863                    assert!(
1864                        *entry <= 1,
1865                        "bot {} ran concurrently with itself: {}",
1866                        bot,
1867                        *entry
1868                    );
1869                }
1870                self.started.lock().await.push(resolved_prompt.clone());
1871                tokio::time::sleep(std::time::Duration::from_millis(120)).await;
1872                {
1873                    let mut active = self.active_by_bot.lock().await;
1874                    if let Some(entry) = active.get_mut(&bot) {
1875                        *entry = entry.saturating_sub(1);
1876                    }
1877                }
1878                Ok(WorkflowDispatchOutcome::Succeeded {
1879                    output: Value::String(resolved_prompt),
1880                    session: None,
1881                })
1882            }
1883
1884            async fn execute_host_executor(
1885                &mut self,
1886                _ctx: WorkflowDispatchRun<'_>,
1887                _node: &HostExecutorNode,
1888                resolved_input: Value,
1889            ) -> Result<WorkflowDispatchOutcome> {
1890                Ok(WorkflowDispatchOutcome::Succeeded {
1891                    output: resolved_input,
1892                    session: None,
1893                })
1894            }
1895        }
1896
1897        let started = Arc::new(Mutex::new(Vec::new()));
1898        let active_by_bot = Arc::new(Mutex::new(HashMap::new()));
1899        let max_active_by_bot = Arc::new(Mutex::new(HashMap::new()));
1900        let mut hooks = SerializingHooks {
1901            started: started.clone(),
1902            active_by_bot: active_by_bot.clone(),
1903            max_active_by_bot: max_active_by_bot.clone(),
1904        };
1905
1906        let started_at = std::time::Instant::now();
1907        let tick = run_tick(&mut rt, &mut hooks, 2).await.unwrap();
1908        let elapsed = started_at.elapsed();
1909
1910        assert_eq!(tick.actions, 2);
1911        let started = started.lock().await.clone();
1912        assert_eq!(started.len(), 2);
1913        assert!(started.contains(&"one".to_string()));
1914        assert!(started.contains(&"three".to_string()));
1915        assert!(!started.contains(&"two".to_string()));
1916        let max_active_by_bot = max_active_by_bot.lock().await;
1917        assert_eq!(max_active_by_bot.get("bot-shared"), Some(&1));
1918        assert_eq!(max_active_by_bot.get("bot-other"), Some(&1));
1919        assert!(
1920            elapsed < std::time::Duration::from_millis(220),
1921            "run_tick took {:?}, expected two distinct bots to run concurrently",
1922            elapsed
1923        );
1924        let _ = fs::remove_dir_all(&run_dir);
1925    }
1926
1927    #[derive(Clone)]
1928    struct CancellingHooks {
1929        run_id: String,
1930        runs_base_dir: PathBuf,
1931        calls: Arc<Mutex<usize>>,
1932        completed: Arc<Mutex<usize>>,
1933    }
1934
1935    #[async_trait]
1936    impl WorkflowExecutionHooks for CancellingHooks {
1937        async fn execute_subagent(
1938            &mut self,
1939            _ctx: WorkflowDispatchRun<'_>,
1940            _node: &SubagentNode,
1941            resolved_prompt: String,
1942        ) -> Result<WorkflowDispatchOutcome> {
1943            let mut calls = self.calls.lock().await;
1944            *calls += 1;
1945            if *calls == 1 {
1946                let run_id = self.run_id.clone();
1947                let runs_base_dir = self.runs_base_dir.clone();
1948                tokio::spawn(async move {
1949                    tokio::time::sleep(std::time::Duration::from_millis(1)).await;
1950                    let mut log = EventLog::new(run_id.clone(), runs_base_dir).unwrap();
1951                    let _ = log.append(EventDraft {
1952                        event_type: "cancelRequested".to_string(),
1953                        actor: WorkflowActor::Human,
1954                        payload: serde_json::json!({
1955                            "target": { "kind": "run", "runId": run_id },
1956                            "reason": "cancel mid tick",
1957                            "by": "tester",
1958                        }),
1959                        timestamp: None,
1960                        payload_hash: None,
1961                    });
1962                });
1963            }
1964            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1965            *self.completed.lock().await += 1;
1966            Ok(WorkflowDispatchOutcome::Succeeded {
1967                output: Value::String(resolved_prompt),
1968                session: None,
1969            })
1970        }
1971
1972        async fn execute_host_executor(
1973            &mut self,
1974            _ctx: WorkflowDispatchRun<'_>,
1975            _node: &HostExecutorNode,
1976            resolved_input: Value,
1977        ) -> Result<WorkflowDispatchOutcome> {
1978            Ok(WorkflowDispatchOutcome::Succeeded {
1979                output: resolved_input,
1980                session: None,
1981            })
1982        }
1983    }
1984
1985    #[tokio::test]
1986    async fn run_tick_stops_between_actions_when_cancel_arrives() {
1987        let run_dir = temp_run_dir("cancel-mid-tick");
1988        let _ = fs::remove_dir_all(&run_dir);
1989        fs::create_dir_all(run_dir.join("blobs")).unwrap();
1990        let paths = crate::BeamPaths::from_root(run_dir.clone());
1991        let run_id = "run-cancel";
1992        crate::bootstrap_workflow_run(
1993            &paths,
1994            crate::BootstrapWorkflowRunInput {
1995                run_id,
1996                workflow_json: r#"{"workflowId":"flow-cancel","version":1,"nodes":{"a":{"type":"subagent","bot":"bot-a","prompt":"one"},"b":{"type":"subagent","bot":"bot-b","prompt":"two"}}}"#,
1997                expected_workflow_id: Some("flow-cancel"),
1998                params: &BTreeMap::new(),
1999                initiator: "cli",
2000                chat_binding: Some(RunChatBinding {
2001                    chat_id: "chat-1".to_string(),
2002                    lark_app_id: "app-1".to_string(),
2003                }),
2004            },
2005        )
2006        .unwrap();
2007        let mut rt = WorkflowRuntimeContext {
2008            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2009            def: WorkflowDefinition {
2010                workflow_id: "flow-cancel".to_string(),
2011                version: 1,
2012                params: None,
2013                defaults: None,
2014                nodes: BTreeMap::from([
2015                    (
2016                        "a".to_string(),
2017                        WorkflowNode::Subagent(SubagentNode {
2018                            base: NodeBase {
2019                                description: None,
2020                                depends: None,
2021                                human_gate: None,
2022                                retry_policy: None,
2023                                timeout_ms: None,
2024                                max_output_bytes: None,
2025                                output_schema: None,
2026                                unsafe_allow_ungated: None,
2027                            },
2028                            bot: "bot-a".to_string(),
2029                            prompt: Value::String("one".to_string()),
2030                            working_dir: None,
2031                            model_overrides: None,
2032                            tool_policy: None,
2033                        }),
2034                    ),
2035                    (
2036                        "b".to_string(),
2037                        WorkflowNode::Subagent(SubagentNode {
2038                            base: NodeBase {
2039                                description: None,
2040                                depends: None,
2041                                human_gate: None,
2042                                retry_policy: None,
2043                                timeout_ms: None,
2044                                max_output_bytes: None,
2045                                output_schema: None,
2046                                unsafe_allow_ungated: None,
2047                            },
2048                            bot: "bot-b".to_string(),
2049                            prompt: Value::String("two".to_string()),
2050                            working_dir: None,
2051                            model_overrides: None,
2052                            tool_policy: None,
2053                        }),
2054                    ),
2055                ]),
2056            },
2057            runs_base_dir: paths.workflow_runs_dir(),
2058        };
2059        let mut hooks = CancellingHooks {
2060            run_id: run_id.to_string(),
2061            runs_base_dir: paths.workflow_runs_dir(),
2062            calls: Arc::new(Mutex::new(0)),
2063            completed: Arc::new(Mutex::new(0)),
2064        };
2065        let started = std::time::Instant::now();
2066        let tick = run_tick(&mut rt, &mut hooks, 2).await.unwrap();
2067        let elapsed = started.elapsed();
2068        assert!(tick.actions < 2);
2069        assert!(tick.snapshot.run.cancelled_run_intent.is_some());
2070        assert!(tick.snapshot.activities.len() >= 1);
2071        assert!(
2072            elapsed < std::time::Duration::from_millis(250),
2073            "run_tick took {:?}, expected cancel to interrupt long-running actions",
2074            elapsed
2075        );
2076        assert_eq!(*hooks.completed.lock().await, 0);
2077        let _ = fs::remove_dir_all(&run_dir);
2078    }
2079
2080    #[tokio::test]
2081    async fn run_tick_skips_dispatch_when_run_cancel_is_pending() {
2082        let run_dir = temp_run_dir("reconcile-cancel");
2083        let _ = fs::remove_dir_all(&run_dir);
2084        fs::create_dir_all(run_dir.join("blobs")).unwrap();
2085        let paths = crate::BeamPaths::from_root(run_dir.clone());
2086        let run_id = "run-reconcile-cancel";
2087        crate::bootstrap_workflow_run(
2088            &paths,
2089            crate::BootstrapWorkflowRunInput {
2090                run_id,
2091                workflow_json: r#"{"workflowId":"flow-reconcile","version":1,"nodes":{"a":{"type":"subagent","bot":"bot-a","prompt":"one"},"b":{"type":"subagent","bot":"bot-b","prompt":"two"}}}"#,
2092                expected_workflow_id: Some("flow-reconcile"),
2093                params: &BTreeMap::new(),
2094                initiator: "cli",
2095                chat_binding: Some(RunChatBinding {
2096                    chat_id: "chat-1".to_string(),
2097                    lark_app_id: "app-1".to_string(),
2098                }),
2099            },
2100        )
2101        .unwrap();
2102        let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
2103        let _ = crate::request_cancel(
2104            &mut log,
2105            crate::RequestCancelInput {
2106                target: serde_json::json!({
2107                    "kind": "run",
2108                    "runId": run_id,
2109                }),
2110                reason: "cancel before dispatch".to_string(),
2111                by: "tester".to_string(),
2112            },
2113            WorkflowActor::Human,
2114        )
2115        .await
2116        .unwrap();
2117        let mut rt = WorkflowRuntimeContext {
2118            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2119            def: WorkflowDefinition {
2120                workflow_id: "flow-reconcile".to_string(),
2121                version: 1,
2122                params: None,
2123                defaults: None,
2124                nodes: BTreeMap::from([
2125                    (
2126                        "a".to_string(),
2127                        WorkflowNode::Subagent(SubagentNode {
2128                            base: NodeBase {
2129                                description: None,
2130                                depends: None,
2131                                human_gate: None,
2132                                retry_policy: None,
2133                                timeout_ms: None,
2134                                max_output_bytes: None,
2135                                output_schema: None,
2136                                unsafe_allow_ungated: None,
2137                            },
2138                            bot: "bot-a".to_string(),
2139                            prompt: Value::String("one".to_string()),
2140                            working_dir: None,
2141                            model_overrides: None,
2142                            tool_policy: None,
2143                        }),
2144                    ),
2145                    (
2146                        "b".to_string(),
2147                        WorkflowNode::Subagent(SubagentNode {
2148                            base: NodeBase {
2149                                description: None,
2150                                depends: None,
2151                                human_gate: None,
2152                                retry_policy: None,
2153                                timeout_ms: None,
2154                                max_output_bytes: None,
2155                                output_schema: None,
2156                                unsafe_allow_ungated: None,
2157                            },
2158                            bot: "bot-b".to_string(),
2159                            prompt: Value::String("two".to_string()),
2160                            working_dir: None,
2161                            model_overrides: None,
2162                            tool_policy: None,
2163                        }),
2164                    ),
2165                ]),
2166            },
2167            runs_base_dir: paths.workflow_runs_dir(),
2168        };
2169        let mut hooks = FakeHooks;
2170        let tick = run_tick(&mut rt, &mut hooks, 2).await.unwrap();
2171        assert_eq!(tick.actions, 0);
2172        assert!(tick.snapshot.run.cancelled_run_intent.is_some());
2173        let _ = fs::remove_dir_all(&run_dir);
2174    }
2175
2176    #[test]
2177    fn orchestrator_action_is_dispatch_classifies_correctly() {
2178        use crate::workflow_definition::{NodeBase, SubagentNode};
2179        use crate::workflow_orchestrator::OrchestratorAction;
2180        let dispatch = OrchestratorAction::DispatchWork {
2181            node_id: "n1".to_string(),
2182            activity_id: "a1".to_string(),
2183            node: crate::WorkflowNode::Subagent(SubagentNode {
2184                base: NodeBase {
2185                    description: None,
2186                    depends: None,
2187                    human_gate: None,
2188                    retry_policy: None,
2189                    timeout_ms: None,
2190                    max_output_bytes: None,
2191                    output_schema: None,
2192                    unsafe_allow_ungated: None,
2193                },
2194                bot: "b1".to_string(),
2195                prompt: serde_json::json!("p"),
2196                working_dir: None,
2197                model_overrides: None,
2198                tool_policy: None,
2199            }),
2200        };
2201        assert!(dispatch.is_dispatch());
2202
2203        let settle = OrchestratorAction::CompleteNodeSucceeded {
2204            node_id: "n1".to_string(),
2205            last_activity_id: "a1".to_string(),
2206            output_ref: None,
2207        };
2208        assert!(!settle.is_dispatch());
2209    }
2210
2211    #[tokio::test]
2212    async fn run_loop_stops_when_progress_is_exhausted() {
2213        let run_dir = temp_run_dir("loop");
2214        let _ = fs::remove_dir_all(&run_dir);
2215        fs::create_dir_all(run_dir.join("blobs")).unwrap();
2216        let paths = crate::BeamPaths::from_root(run_dir.clone());
2217        let params: BTreeMap<String, Value> =
2218            BTreeMap::from([(String::from("name"), Value::String("beam".to_string()))]);
2219        let run_id = "run-1";
2220        crate::bootstrap_workflow_run(
2221            &paths,
2222            crate::BootstrapWorkflowRunInput {
2223                run_id,
2224                workflow_json: r#"{"workflowId":"flow-a","version":1,"params":{"name":{"type":"string"}},"nodes":{"a":{"type":"subagent","bot":"bot-a","prompt":"hello ${params.name}"}}}"#,
2225                expected_workflow_id: Some("flow-a"),
2226                params: &params,
2227                initiator: "cli",
2228                chat_binding: Some(RunChatBinding {
2229                    chat_id: "chat-1".to_string(),
2230                    lark_app_id: "app-1".to_string(),
2231                }),
2232            },
2233        )
2234        .unwrap();
2235        let mut rt = WorkflowRuntimeContext {
2236            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2237            def: workflow_def(),
2238            runs_base_dir: paths.workflow_runs_dir(),
2239        };
2240        let mut hooks = FakeHooks;
2241        let result = run_loop(&mut rt, &mut hooks, 3, 1).await.unwrap();
2242        assert!(matches!(
2243            result.reason,
2244            RunLoopStopReason::Terminal | RunLoopStopReason::NoProgress
2245        ));
2246        assert!(result.ticks > 0);
2247        let _ = fs::remove_dir_all(&run_dir);
2248    }
2249
2250    // --- Recovery phase tests (Task 4.1) ---
2251
2252    /// A hook that counts how many times `recover_dangling_effects` is called.
2253    #[derive(Clone)]
2254    struct RecoveryCountingHooks {
2255        recovery_calls: Arc<Mutex<usize>>,
2256    }
2257
2258    #[async_trait]
2259    impl WorkflowExecutionHooks for RecoveryCountingHooks {
2260        async fn execute_subagent(
2261            &mut self,
2262            _ctx: WorkflowDispatchRun<'_>,
2263            _node: &SubagentNode,
2264            resolved_prompt: String,
2265        ) -> Result<WorkflowDispatchOutcome> {
2266            Ok(WorkflowDispatchOutcome::Succeeded {
2267                output: Value::String(resolved_prompt),
2268                session: None,
2269            })
2270        }
2271
2272        async fn execute_host_executor(
2273            &mut self,
2274            _ctx: WorkflowDispatchRun<'_>,
2275            _node: &HostExecutorNode,
2276            resolved_input: Value,
2277        ) -> Result<WorkflowDispatchOutcome> {
2278            Ok(WorkflowDispatchOutcome::Succeeded {
2279                output: resolved_input,
2280                session: None,
2281            })
2282        }
2283
2284        async fn recover_dangling_effects(
2285            &mut self,
2286            _log: &mut EventLog,
2287            _snapshot: &RunSnapshotDTO,
2288        ) -> Result<RecoveryResult> {
2289            *self.recovery_calls.lock().await += 1;
2290            Ok(RecoveryResult {
2291                had_progress: false,
2292                has_remaining_dangling: true,
2293            })
2294        }
2295    }
2296
2297    #[tokio::test]
2298    async fn run_loop_calls_recovery_when_dangling_effects_present() {
2299        let run_dir = temp_run_dir("recovery-call");
2300        let _ = fs::remove_dir_all(&run_dir);
2301        fs::create_dir_all(run_dir.join("blobs")).unwrap();
2302        let paths = crate::BeamPaths::from_root(run_dir.clone());
2303        let run_id = "run-recovery-call";
2304        let _ = crate::bootstrap_workflow_run(
2305            &paths,
2306            crate::BootstrapWorkflowRunInput {
2307                run_id,
2308                workflow_json: r#"{"workflowId":"flow-recovery","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-send","input":{"chatId":"chat-1","content":"hello"},"unsafeAllowUngated":true}}}"#,
2309                expected_workflow_id: Some("flow-recovery"),
2310                params: &BTreeMap::new(),
2311                initiator: "cli",
2312                chat_binding: None,
2313            },
2314        )
2315        .unwrap();
2316
2317        // Activity id that the orchestrator expects for node "a" work.
2318        let work_activity_id = format!("{}::work::a", run_id);
2319        let work_attempt_id = format!("{}::work::a::att-1", run_id);
2320
2321        // Write events that create a dangling effectAttempted activity matching
2322        // the orchestrator's work activity id.
2323        {
2324            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
2325            let _ = log
2326                .append(EventDraft {
2327                    event_type: "attemptCreated".to_string(),
2328                    actor: WorkflowActor::Scheduler,
2329                    payload: serde_json::json!({
2330                        "nodeId": "a",
2331                        "activityId": &work_activity_id,
2332                        "attemptId": &work_attempt_id,
2333                        "attemptNumber": 1,
2334                        "inputRef": {
2335                            "outputHash": "sha256:aa",
2336                            "outputPath": "/tmp/aa",
2337                            "outputBytes": 2,
2338                            "outputSchemaVersion": 1,
2339                            "contentType": "application/json"
2340                        }
2341                    }),
2342                    timestamp: None,
2343                    payload_hash: None,
2344                })
2345                .unwrap();
2346            let _ = log
2347                .append(EventDraft {
2348                    event_type: "effectAttempted".to_string(),
2349                    actor: WorkflowActor::HostExecutor,
2350                    payload: serde_json::json!({
2351                        "activityId": &work_activity_id,
2352                        "attemptId": &work_attempt_id,
2353                        "idempotencyKey": "wf_key_recovery",
2354                        "inputHash": "sha256:bb",
2355                        "idempotencyTtlMs": 60000u64,
2356                        "provider": "feishu-im",
2357                    }),
2358                    timestamp: None,
2359                    payload_hash: None,
2360                })
2361                .unwrap();
2362        }
2363
2364        let mut rt = WorkflowRuntimeContext {
2365            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2366            def: WorkflowDefinition {
2367                workflow_id: "flow-recovery".to_string(),
2368                version: 1,
2369                params: None,
2370                defaults: None,
2371                nodes: BTreeMap::from([(
2372                    "a".to_string(),
2373                    WorkflowNode::HostExecutor(HostExecutorNode {
2374                        base: NodeBase {
2375                            description: None,
2376                            depends: None,
2377                            human_gate: None,
2378                            retry_policy: None,
2379                            timeout_ms: None,
2380                            max_output_bytes: None,
2381                            output_schema: None,
2382                            unsafe_allow_ungated: Some(true),
2383                        },
2384                        executor: "feishu-send".to_string(),
2385                        input: serde_json::json!({"chatId":"chat-1","content":"hello"}),
2386                    }),
2387                )]),
2388            },
2389            runs_base_dir: paths.workflow_runs_dir(),
2390        };
2391
2392        let recovery_calls = Arc::new(Mutex::new(0usize));
2393        let mut hooks = RecoveryCountingHooks {
2394            recovery_calls: recovery_calls.clone(),
2395        };
2396        let result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
2397
2398        let calls = *recovery_calls.lock().await;
2399        assert!(
2400            calls > 0,
2401            "expected recover_dangling_effects to be called at least once, got {calls}"
2402        );
2403        // Since recovery returned had_progress=false, the loop should fall
2404        // through to run_tick and eventually stop (not loop forever on recovery).
2405        assert!(
2406            matches!(
2407                result.reason,
2408                RunLoopStopReason::NoProgress | RunLoopStopReason::AwaitingWait
2409            ),
2410            "expected NoProgress or AwaitingWait, got {:?}",
2411            result.reason
2412        );
2413        let _ = fs::remove_dir_all(&run_dir);
2414    }
2415
2416    /// A hook that simulates successful recovery: writes activitySucceeded for
2417    /// every dangling effectAttempted activity.
2418    #[derive(Clone)]
2419    struct RecoveringHooks;
2420
2421    #[async_trait]
2422    impl WorkflowExecutionHooks for RecoveringHooks {
2423        async fn execute_subagent(
2424            &mut self,
2425            _ctx: WorkflowDispatchRun<'_>,
2426            _node: &SubagentNode,
2427            resolved_prompt: String,
2428        ) -> Result<WorkflowDispatchOutcome> {
2429            Ok(WorkflowDispatchOutcome::Succeeded {
2430                output: Value::String(resolved_prompt),
2431                session: None,
2432            })
2433        }
2434
2435        async fn execute_host_executor(
2436            &mut self,
2437            _ctx: WorkflowDispatchRun<'_>,
2438            _node: &HostExecutorNode,
2439            resolved_input: Value,
2440        ) -> Result<WorkflowDispatchOutcome> {
2441            Ok(WorkflowDispatchOutcome::Succeeded {
2442                output: resolved_input,
2443                session: None,
2444            })
2445        }
2446
2447        async fn recover_dangling_effects(
2448            &mut self,
2449            log: &mut EventLog,
2450            snapshot: &RunSnapshotDTO,
2451        ) -> Result<RecoveryResult> {
2452            let mut had_progress = false;
2453            for activity_id in &snapshot.dangling.effect_attempted {
2454                if let Some(activity) = snapshot
2455                    .activities
2456                    .iter()
2457                    .find(|a| &a.activity_id == activity_id)
2458                {
2459                    if let Some(latest) = activity.attempts.last() {
2460                        let output_ref = WorkflowOutputRef {
2461                            output_hash: "sha256:recovered".to_string(),
2462                            output_path: "/tmp/recovered".to_string(),
2463                            output_bytes: 3,
2464                            output_schema_version: 1,
2465                            content_type: Some("application/json".to_string()),
2466                        };
2467                        let _ = log.append(EventDraft {
2468                            event_type: "activitySucceeded".to_string(),
2469                            actor: WorkflowActor::System,
2470                            payload: serde_json::json!({
2471                                "activityId": activity_id,
2472                                "attemptId": &latest.attempt_id,
2473                                "outputRef": output_ref,
2474                            }),
2475                            timestamp: None,
2476                            payload_hash: None,
2477                        })?;
2478                        had_progress = true;
2479                    }
2480                }
2481            }
2482            Ok(RecoveryResult {
2483                had_progress,
2484                has_remaining_dangling: false,
2485            })
2486        }
2487    }
2488
2489    #[tokio::test]
2490    async fn run_loop_replays_after_recovery_writes_events() {
2491        let run_dir = temp_run_dir("recovery-replay");
2492        let _ = fs::remove_dir_all(&run_dir);
2493        fs::create_dir_all(run_dir.join("blobs")).unwrap();
2494        let paths = crate::BeamPaths::from_root(run_dir.clone());
2495        let run_id = "run-recovery-replay";
2496        let _ = crate::bootstrap_workflow_run(
2497            &paths,
2498            crate::BootstrapWorkflowRunInput {
2499                run_id,
2500                workflow_json: r#"{"workflowId":"flow-replay","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-send","input":{"chatId":"chat-1","content":"hello"},"unsafeAllowUngated":true}}}"#,
2501                expected_workflow_id: Some("flow-replay"),
2502                params: &BTreeMap::new(),
2503                initiator: "cli",
2504                chat_binding: None,
2505            },
2506        )
2507        .unwrap();
2508
2509        // Activity id that the orchestrator expects for node "a" work.
2510        let work_activity_id = format!("{}::work::a", run_id);
2511        let work_attempt_id = format!("{}::work::a::att-1", run_id);
2512
2513        // Write events that create a dangling effectAttempted activity
2514        {
2515            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
2516            let _ = log
2517                .append(EventDraft {
2518                    event_type: "attemptCreated".to_string(),
2519                    actor: WorkflowActor::Scheduler,
2520                    payload: serde_json::json!({
2521                        "nodeId": "a",
2522                        "activityId": &work_activity_id,
2523                        "attemptId": &work_attempt_id,
2524                        "attemptNumber": 1,
2525                        "inputRef": {
2526                            "outputHash": "sha256:aa",
2527                            "outputPath": "/tmp/aa",
2528                            "outputBytes": 2,
2529                            "outputSchemaVersion": 1,
2530                            "contentType": "application/json"
2531                        }
2532                    }),
2533                    timestamp: None,
2534                    payload_hash: None,
2535                })
2536                .unwrap();
2537            let _ = log
2538                .append(EventDraft {
2539                    event_type: "effectAttempted".to_string(),
2540                    actor: WorkflowActor::HostExecutor,
2541                    payload: serde_json::json!({
2542                        "activityId": &work_activity_id,
2543                        "attemptId": &work_attempt_id,
2544                        "idempotencyKey": "wf_key_replay",
2545                        "inputHash": "sha256:bb",
2546                        "idempotencyTtlMs": 60000u64,
2547                        "provider": "feishu-im",
2548                    }),
2549                    timestamp: None,
2550                    payload_hash: None,
2551                })
2552                .unwrap();
2553        }
2554
2555        let mut rt = WorkflowRuntimeContext {
2556            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2557            def: WorkflowDefinition {
2558                workflow_id: "flow-replay".to_string(),
2559                version: 1,
2560                params: None,
2561                defaults: None,
2562                nodes: BTreeMap::from([(
2563                    "a".to_string(),
2564                    WorkflowNode::HostExecutor(HostExecutorNode {
2565                        base: NodeBase {
2566                            description: None,
2567                            depends: None,
2568                            human_gate: None,
2569                            retry_policy: None,
2570                            timeout_ms: None,
2571                            max_output_bytes: None,
2572                            output_schema: None,
2573                            unsafe_allow_ungated: Some(true),
2574                        },
2575                        executor: "feishu-send".to_string(),
2576                        input: serde_json::json!({"chatId":"chat-1","content":"hello"}),
2577                    }),
2578                )]),
2579            },
2580            runs_base_dir: paths.workflow_runs_dir(),
2581        };
2582
2583        let mut hooks = RecoveringHooks;
2584        let result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
2585
2586        // After recovery writes activitySucceeded, the orchestrator should
2587        // be able to complete the node and the run.  Verify that the run
2588        // reached a terminal state or made measurable progress.
2589        let final_snapshot = read_snapshot(&rt).await.unwrap();
2590        assert!(
2591            matches!(
2592                final_snapshot.run.status,
2593                RunStatus::Succeeded | RunStatus::Failed | RunStatus::Cancelled
2594            ) || result.ticks > 0,
2595            "expected recovery to allow progress; reason={:?} ticks={} status={:?}",
2596            result.reason,
2597            result.ticks,
2598            final_snapshot.run.status
2599        );
2600        // Verify the recovery event was written
2601        let events = rt.log.read_all().unwrap();
2602        let has_recovered = events
2603            .iter()
2604            .any(|e| e.event_type == "activitySucceeded" && e.actor == WorkflowActor::System);
2605        assert!(
2606            has_recovered,
2607            "expected a system activitySucceeded from recovery"
2608        );
2609        let _ = fs::remove_dir_all(&run_dir);
2610    }
2611
2612    #[tokio::test]
2613    async fn run_loop_no_infinite_loop_when_recovery_cannot_progress() {
2614        let run_dir = temp_run_dir("recovery-stuck");
2615        let _ = fs::remove_dir_all(&run_dir);
2616        fs::create_dir_all(run_dir.join("blobs")).unwrap();
2617        let paths = crate::BeamPaths::from_root(run_dir.clone());
2618        let run_id = "run-recovery-stuck";
2619        let _ = crate::bootstrap_workflow_run(
2620            &paths,
2621            crate::BootstrapWorkflowRunInput {
2622                run_id,
2623                workflow_json: r#"{"workflowId":"flow-stuck","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-send","input":{"chatId":"chat-1","content":"hello"},"unsafeAllowUngated":true}}}"#,
2624                expected_workflow_id: Some("flow-stuck"),
2625                params: &BTreeMap::new(),
2626                initiator: "cli",
2627                chat_binding: None,
2628            },
2629        )
2630        .unwrap();
2631
2632        // Activity id that the orchestrator expects for node "a" work.
2633        let work_activity_id = format!("{}::work::a", run_id);
2634        let work_attempt_id = format!("{}::work::a::att-1", run_id);
2635
2636        // Write events that create a dangling effectAttempted activity
2637        {
2638            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
2639            let _ = log
2640                .append(EventDraft {
2641                    event_type: "attemptCreated".to_string(),
2642                    actor: WorkflowActor::Scheduler,
2643                    payload: serde_json::json!({
2644                        "nodeId": "a",
2645                        "activityId": &work_activity_id,
2646                        "attemptId": &work_attempt_id,
2647                        "attemptNumber": 1,
2648                        "inputRef": {
2649                            "outputHash": "sha256:aa",
2650                            "outputPath": "/tmp/aa",
2651                            "outputBytes": 2,
2652                            "outputSchemaVersion": 1,
2653                            "contentType": "application/json"
2654                        }
2655                    }),
2656                    timestamp: None,
2657                    payload_hash: None,
2658                })
2659                .unwrap();
2660            let _ = log
2661                .append(EventDraft {
2662                    event_type: "effectAttempted".to_string(),
2663                    actor: WorkflowActor::HostExecutor,
2664                    payload: serde_json::json!({
2665                        "activityId": &work_activity_id,
2666                        "attemptId": &work_attempt_id,
2667                        "idempotencyKey": "wf_key_stuck",
2668                        "inputHash": "sha256:bb",
2669                        "idempotencyTtlMs": 60000u64,
2670                        "provider": "unknown-provider",
2671                    }),
2672                    timestamp: None,
2673                    payload_hash: None,
2674                })
2675                .unwrap();
2676        }
2677
2678        let mut rt = WorkflowRuntimeContext {
2679            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2680            def: WorkflowDefinition {
2681                workflow_id: "flow-stuck".to_string(),
2682                version: 1,
2683                params: None,
2684                defaults: None,
2685                nodes: BTreeMap::from([(
2686                    "a".to_string(),
2687                    WorkflowNode::HostExecutor(HostExecutorNode {
2688                        base: NodeBase {
2689                            description: None,
2690                            depends: None,
2691                            human_gate: None,
2692                            retry_policy: None,
2693                            timeout_ms: None,
2694                            max_output_bytes: None,
2695                            output_schema: None,
2696                            unsafe_allow_ungated: Some(true),
2697                        },
2698                        executor: "feishu-send".to_string(),
2699                        input: serde_json::json!({"chatId":"chat-1","content":"hello"}),
2700                    }),
2701                )]),
2702            },
2703            runs_base_dir: paths.workflow_runs_dir(),
2704        };
2705
2706        // Use FakeHooks which returns had_progress=false for recovery.
2707        let mut hooks = FakeHooks;
2708        let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
2709
2710        // The loop must NOT return MaxTicks (that would mean it consumed all
2711        // ticks in unsuccessful recovery attempts).  With no real progress,
2712        // it should return NoProgress or AwaitingWait.
2713        assert!(
2714            !matches!(result.reason, RunLoopStopReason::MaxTicks),
2715            "expected run_loop to stop without exhausting max_ticks on unrecoverable dangling effects"
2716        );
2717        assert!(
2718            matches!(
2719                result.reason,
2720                RunLoopStopReason::NoProgress | RunLoopStopReason::AwaitingWait
2721            ),
2722            "expected NoProgress or AwaitingWait, got {:?}",
2723            result.reason
2724        );
2725        let _ = fs::remove_dir_all(&run_dir);
2726    }
2727
2728    #[tokio::test]
2729    async fn default_recovery_result_has_correct_semantics() {
2730        // Default implementation: had_progress=false always.
2731        // has_remaining_dangling should match whether the snapshot actually
2732        // contains dangling effect_attempted activities.
2733
2734        // Snapshot with no dangling effects → has_remaining_dangling=false
2735        let empty_snapshot = RunSnapshotDTO {
2736            run_id: "r".to_string(),
2737            run: RunState {
2738                run_id: "r".to_string(),
2739                status: RunStatus::Running,
2740                workflow_id: None,
2741                revision_id: None,
2742                initiator: None,
2743                input: None,
2744                output: None,
2745                failed_node_id: None,
2746                root_cause_event_id: None,
2747                cancel_origin_event_id: None,
2748                bot_snapshots: None,
2749                cancelled_run_intent: None,
2750                cancelled_node_intents: BTreeMap::new(),
2751            },
2752            last_seq: 0,
2753            nodes: vec![],
2754            activities: vec![],
2755            loops: None,
2756            dangling: DanglingSnapshot {
2757                activities: vec![],
2758                effect_attempted: vec![],
2759                waits: vec![],
2760                wait_resolutions: vec![],
2761                cancels: vec![],
2762            },
2763            outputs: BTreeMap::new(),
2764            attempt_io: BTreeMap::new(),
2765            chat_binding: None,
2766            updated_at: 0,
2767        };
2768
2769        // Need a real EventLog for the &mut reference, but FakeHooks doesn't use it.
2770        let run_dir = temp_run_dir("recovery-semantics");
2771        let _ = fs::remove_dir_all(&run_dir);
2772        fs::create_dir_all(&run_dir).unwrap();
2773        let paths = crate::BeamPaths::from_root(run_dir.clone());
2774        let mut log = EventLog::new("r", paths.workflow_runs_dir()).unwrap();
2775
2776        // Use the trait method via a concrete type to test default impl.
2777        // We call it through a concrete struct that doesn't override the default.
2778        #[derive(Clone)]
2779        struct DefaultingHooks;
2780        #[async_trait]
2781        impl WorkflowExecutionHooks for DefaultingHooks {
2782            async fn execute_subagent(
2783                &mut self,
2784                _ctx: WorkflowDispatchRun<'_>,
2785                _node: &SubagentNode,
2786                _resolved_prompt: String,
2787            ) -> Result<WorkflowDispatchOutcome> {
2788                unreachable!()
2789            }
2790            async fn execute_host_executor(
2791                &mut self,
2792                _ctx: WorkflowDispatchRun<'_>,
2793                _node: &HostExecutorNode,
2794                _resolved_input: Value,
2795            ) -> Result<WorkflowDispatchOutcome> {
2796                unreachable!()
2797            }
2798            // Uses default recover_dangling_effects
2799        }
2800
2801        let mut hooks = DefaultingHooks;
2802
2803        // Empty: has_remaining_dangling should be false
2804        let result = hooks
2805            .recover_dangling_effects(&mut log, &empty_snapshot)
2806            .await
2807            .unwrap();
2808        assert!(!result.had_progress, "default: had_progress must be false");
2809        assert!(
2810            !result.has_remaining_dangling,
2811            "empty dangling → has_remaining_dangling must be false"
2812        );
2813
2814        // With dangling effects: has_remaining_dangling should be true
2815        let dangling_snapshot = RunSnapshotDTO {
2816            dangling: DanglingSnapshot {
2817                effect_attempted: vec!["act-1".to_string()],
2818                ..empty_snapshot.dangling.clone()
2819            },
2820            ..empty_snapshot.clone()
2821        };
2822        let result2 = hooks
2823            .recover_dangling_effects(&mut log, &dangling_snapshot)
2824            .await
2825            .unwrap();
2826        assert!(!result2.had_progress, "default: had_progress must be false");
2827        assert!(
2828            result2.has_remaining_dangling,
2829            "dangling present → has_remaining_dangling must be true"
2830        );
2831
2832        let _ = fs::remove_dir_all(&run_dir);
2833    }
2834
2835    // --- Wait resolution projection tests (Task 4.2) ---
2836
2837    /// Verify that an open wait (waitCreated without resolution) causes
2838    /// run_loop to return AwaitingWait rather than NoProgress.
2839    #[tokio::test]
2840    async fn open_wait_makes_run_loop_return_awaiting_wait() {
2841        let run_dir = temp_run_dir("open-wait");
2842        let _ = fs::remove_dir_all(&run_dir);
2843        fs::create_dir_all(run_dir.join("blobs")).unwrap();
2844        let paths = crate::BeamPaths::from_root(run_dir.clone());
2845        let run_id = "run-open-wait";
2846        crate::bootstrap_workflow_run(
2847            &paths,
2848            crate::BootstrapWorkflowRunInput {
2849                run_id,
2850                workflow_json: r#"{"workflowId":"flow-open-wait","version":1,"nodes":{"gate":{"type":"hostExecutor","executor":"beam-schedule","input":{"prompt":"approve"},"humanGate":{"stage":"gate","prompt":"approve?","approvers":["admin"]}},"sink":{"type":"subagent","bot":"bot-a","prompt":"done","depends":["gate"]}}}"#,
2851                expected_workflow_id: Some("flow-open-wait"),
2852                params: &BTreeMap::new(),
2853                initiator: "cli",
2854                chat_binding: Some(RunChatBinding {
2855                    chat_id: "chat-1".to_string(),
2856                    lark_app_id: "app-1".to_string(),
2857                }),
2858            },
2859        )
2860        .unwrap();
2861
2862        // Write a waitCreated event directly — this simulates a workflow that
2863        // dispatched a gate and created a wait but no resolution has arrived yet.
2864        let gate_activity_id = format!("{}::gate::gate", run_id);
2865        let gate_attempt_id = format!("{}::gate::gate::att-1", run_id);
2866        {
2867            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
2868            let _ = log
2869                .append(EventDraft {
2870                    event_type: "attemptCreated".to_string(),
2871                    actor: WorkflowActor::Scheduler,
2872                    payload: serde_json::json!({
2873                        "nodeId": "gate",
2874                        "activityId": &gate_activity_id,
2875                        "attemptId": &gate_attempt_id,
2876                        "attemptNumber": 1,
2877                        "inputRef": {
2878                            "outputHash": "sha256:aa",
2879                            "outputPath": "/tmp/aa",
2880                            "outputBytes": 2,
2881                            "outputSchemaVersion": 1,
2882                            "contentType": "application/json"
2883                        }
2884                    }),
2885                    timestamp: None,
2886                    payload_hash: None,
2887                })
2888                .unwrap();
2889            let _ = log
2890                .append(EventDraft {
2891                    event_type: "waitCreated".to_string(),
2892                    actor: WorkflowActor::Scheduler,
2893                    payload: serde_json::json!({
2894                        "activityId": &gate_activity_id,
2895                        "attemptId": &gate_attempt_id,
2896                        "nodeId": "gate",
2897                        "waitKind": "human-gate",
2898                    }),
2899                    timestamp: None,
2900                    payload_hash: None,
2901                })
2902                .unwrap();
2903        }
2904
2905        let mut rt = WorkflowRuntimeContext {
2906            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2907            def: WorkflowDefinition {
2908                workflow_id: "flow-open-wait".to_string(),
2909                version: 1,
2910                params: None,
2911                defaults: None,
2912                nodes: BTreeMap::from([
2913                    (
2914                        "gate".to_string(),
2915                        WorkflowNode::HostExecutor(HostExecutorNode {
2916                            base: NodeBase {
2917                                description: None,
2918                                depends: None,
2919                                human_gate: Some(crate::workflow_definition::HumanGate {
2920                                    stage: "gate".to_string(),
2921                                    prompt: Value::String("approve?".to_string()),
2922                                    approvers: Some(vec!["admin".to_string()]),
2923                                    deadline_ms: None,
2924                                    on_timeout: None,
2925                                }),
2926                                retry_policy: None,
2927                                timeout_ms: None,
2928                                max_output_bytes: None,
2929                                output_schema: None,
2930                                unsafe_allow_ungated: Some(true),
2931                            },
2932                            executor: "beam-schedule".to_string(),
2933                            input: serde_json::json!({"prompt":"approve"}),
2934                        }),
2935                    ),
2936                    (
2937                        "sink".to_string(),
2938                        WorkflowNode::Subagent(SubagentNode {
2939                            base: NodeBase {
2940                                description: None,
2941                                depends: Some(vec!["gate".to_string()]),
2942                                human_gate: None,
2943                                retry_policy: None,
2944                                timeout_ms: None,
2945                                max_output_bytes: None,
2946                                output_schema: None,
2947                                unsafe_allow_ungated: None,
2948                            },
2949                            bot: "bot-a".to_string(),
2950                            prompt: Value::String("done".to_string()),
2951                            working_dir: None,
2952                            model_overrides: None,
2953                            tool_policy: None,
2954                        }),
2955                    ),
2956                ]),
2957            },
2958            runs_base_dir: paths.workflow_runs_dir(),
2959        };
2960
2961        // Verify snapshot shows open wait (in waits, not in wait_resolutions)
2962        let snap = read_snapshot(&rt).await.unwrap();
2963        assert!(
2964            !snap.dangling.waits.is_empty(),
2965            "expected open wait in dangling.waits, got {:?}",
2966            snap.dangling.waits
2967        );
2968        assert!(
2969            snap.dangling.wait_resolutions.is_empty(),
2970            "expected no wait_resolutions for open wait"
2971        );
2972
2973        let mut hooks = FakeHooks;
2974        let result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
2975        assert_eq!(
2976            result.reason,
2977            RunLoopStopReason::AwaitingWait,
2978            "expected AwaitingWait for open wait, got {:?}",
2979            result.reason
2980        );
2981
2982        let _ = fs::remove_dir_all(&run_dir);
2983    }
2984
2985    /// Verify that a resolved wait without a terminal event (dangling
2986    /// wait_resolution) gets materialized during run_loop's recovery phase,
2987    /// allowing the workflow to continue.
2988    #[tokio::test]
2989    async fn run_loop_materializes_terminal_for_resolved_wait() {
2990        let run_dir = temp_run_dir("resolved-wait");
2991        let _ = fs::remove_dir_all(&run_dir);
2992        fs::create_dir_all(run_dir.join("blobs")).unwrap();
2993        let paths = crate::BeamPaths::from_root(run_dir.clone());
2994        let run_id = "run-resolved-wait";
2995        crate::bootstrap_workflow_run(
2996            &paths,
2997            crate::BootstrapWorkflowRunInput {
2998                run_id,
2999                workflow_json: r#"{"workflowId":"flow-resolved-wait","version":1,"nodes":{"gate":{"type":"hostExecutor","executor":"beam-schedule","input":{"prompt":"approve"},"humanGate":{"stage":"gate","prompt":"approve?","approvers":["admin"]}},"sink":{"type":"subagent","bot":"bot-a","prompt":"done","depends":["gate"]}}}"#,
3000                expected_workflow_id: Some("flow-resolved-wait"),
3001                params: &BTreeMap::new(),
3002                initiator: "cli",
3003                chat_binding: Some(RunChatBinding {
3004                    chat_id: "chat-1".to_string(),
3005                    lark_app_id: "app-1".to_string(),
3006                }),
3007            },
3008        )
3009        .unwrap();
3010
3011        // Write attemptCreated + waitCreated + waitResolved (approved) but NO terminal.
3012        // This simulates a crash after the wait was resolved but before the terminal
3013        // event was written.
3014        let gate_activity_id = format!("{}::gate::gate", run_id);
3015        let gate_attempt_id = format!("{}::gate::gate::att-1", run_id);
3016        {
3017            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3018            let _ = log
3019                .append(EventDraft {
3020                    event_type: "attemptCreated".to_string(),
3021                    actor: WorkflowActor::Scheduler,
3022                    payload: serde_json::json!({
3023                        "nodeId": "gate",
3024                        "activityId": &gate_activity_id,
3025                        "attemptId": &gate_attempt_id,
3026                        "attemptNumber": 1,
3027                        "inputRef": {
3028                            "outputHash": "sha256:aa",
3029                            "outputPath": "/tmp/aa",
3030                            "outputBytes": 2,
3031                            "outputSchemaVersion": 1,
3032                            "contentType": "application/json"
3033                        }
3034                    }),
3035                    timestamp: None,
3036                    payload_hash: None,
3037                })
3038                .unwrap();
3039            let _ = log
3040                .append(EventDraft {
3041                    event_type: "waitCreated".to_string(),
3042                    actor: WorkflowActor::Scheduler,
3043                    payload: serde_json::json!({
3044                        "activityId": &gate_activity_id,
3045                        "attemptId": &gate_attempt_id,
3046                        "nodeId": "gate",
3047                        "waitKind": "human-gate",
3048                    }),
3049                    timestamp: None,
3050                    payload_hash: None,
3051                })
3052                .unwrap();
3053            let _ = log
3054                .append(EventDraft {
3055                    event_type: "waitResolved".to_string(),
3056                    actor: WorkflowActor::Human,
3057                    payload: serde_json::json!({
3058                        "activityId": &gate_activity_id,
3059                        "resolution": "approved",
3060                        "by": "admin",
3061                        "comment": "go ahead",
3062                    }),
3063                    timestamp: None,
3064                    payload_hash: None,
3065                })
3066                .unwrap();
3067        }
3068
3069        let mut rt = WorkflowRuntimeContext {
3070            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
3071            def: WorkflowDefinition {
3072                workflow_id: "flow-resolved-wait".to_string(),
3073                version: 1,
3074                params: None,
3075                defaults: None,
3076                nodes: BTreeMap::from([
3077                    (
3078                        "gate".to_string(),
3079                        WorkflowNode::HostExecutor(HostExecutorNode {
3080                            base: NodeBase {
3081                                description: None,
3082                                depends: None,
3083                                human_gate: Some(crate::workflow_definition::HumanGate {
3084                                    stage: "gate".to_string(),
3085                                    prompt: Value::String("approve?".to_string()),
3086                                    approvers: Some(vec!["admin".to_string()]),
3087                                    deadline_ms: None,
3088                                    on_timeout: None,
3089                                }),
3090                                retry_policy: None,
3091                                timeout_ms: None,
3092                                max_output_bytes: None,
3093                                output_schema: None,
3094                                unsafe_allow_ungated: Some(true),
3095                            },
3096                            executor: "beam-schedule".to_string(),
3097                            input: serde_json::json!({"prompt":"approve"}),
3098                        }),
3099                    ),
3100                    (
3101                        "sink".to_string(),
3102                        WorkflowNode::Subagent(SubagentNode {
3103                            base: NodeBase {
3104                                description: None,
3105                                depends: Some(vec!["gate".to_string()]),
3106                                human_gate: None,
3107                                retry_policy: None,
3108                                timeout_ms: None,
3109                                max_output_bytes: None,
3110                                output_schema: None,
3111                                unsafe_allow_ungated: None,
3112                            },
3113                            bot: "bot-a".to_string(),
3114                            prompt: Value::String("done".to_string()),
3115                            working_dir: None,
3116                            model_overrides: None,
3117                            tool_policy: None,
3118                        }),
3119                    ),
3120                ]),
3121            },
3122            runs_base_dir: paths.workflow_runs_dir(),
3123        };
3124
3125        // Verify snapshot shows resolved wait in wait_resolutions, NOT in waits
3126        let snap = read_snapshot(&rt).await.unwrap();
3127        assert!(
3128            snap.dangling.waits.is_empty(),
3129            "expected no open waits after resolution, got {:?}",
3130            snap.dangling.waits
3131        );
3132        assert!(
3133            !snap.dangling.wait_resolutions.is_empty(),
3134            "expected resolved wait in wait_resolutions, got {:?}",
3135            snap.dangling.wait_resolutions
3136        );
3137        assert_eq!(
3138            snap.dangling.wait_resolutions,
3139            vec![gate_activity_id.clone()],
3140            "expected gate activity in wait_resolutions"
3141        );
3142
3143        let mut hooks = FakeHooks;
3144        let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
3145
3146        // run_loop should have materialized the terminal event (activitySucceeded
3147        // for approved wait), allowing the orchestrator to proceed past the gate.
3148        let final_snap = read_snapshot(&rt).await.unwrap();
3149        assert!(
3150            final_snap.dangling.wait_resolutions.is_empty(),
3151            "expected no remaining wait_resolutions after recovery, got {:?}",
3152            final_snap.dangling.wait_resolutions
3153        );
3154
3155        let events = rt.log.read_all().unwrap();
3156        let recovered_terminal = events.iter().any(|e| {
3157            e.event_type == "activitySucceeded"
3158                && e.payload.get("activityId").and_then(Value::as_str) == Some(&gate_activity_id)
3159                && e.actor == WorkflowActor::Scheduler
3160        });
3161        assert!(
3162            recovered_terminal,
3163            "expected a scheduler activitySucceeded for the gate after wait resolution recovery"
3164        );
3165
3166        // The loop should either have progressed (ticks > 0) or stopped at terminal.
3167        assert!(
3168            result.ticks > 0 || matches!(result.reason, RunLoopStopReason::Terminal),
3169            "expected progress after wait resolution recovery; reason={:?} ticks={}",
3170            result.reason,
3171            result.ticks
3172        );
3173
3174        let _ = fs::remove_dir_all(&run_dir);
3175    }
3176
3177    /// Verify that cancelRequested (run) propagation writes activityCanceled
3178    /// for an open human-gate activity before writing runCanceled.
3179    #[tokio::test]
3180    async fn cancel_propagation_writes_activity_canceled_before_run_canceled() {
3181        let run_dir = temp_run_dir("cancel-propagate");
3182        let _ = fs::remove_dir_all(&run_dir);
3183        fs::create_dir_all(run_dir.join("blobs")).unwrap();
3184        let paths = crate::BeamPaths::from_root(run_dir.clone());
3185        let run_id = "run-cancel-propagate";
3186        crate::bootstrap_workflow_run(
3187            &paths,
3188            crate::BootstrapWorkflowRunInput {
3189                run_id,
3190                workflow_json: r#"{"workflowId":"flow-cancel-propagate","version":1,"nodes":{"gate":{"type":"hostExecutor","executor":"beam-schedule","input":{"prompt":"approve"},"humanGate":{"stage":"gate","prompt":"approve?","approvers":["admin"]}},"sink":{"type":"subagent","bot":"bot-a","prompt":"done","depends":["gate"]}}}"#,
3191                expected_workflow_id: Some("flow-cancel-propagate"),
3192                params: &BTreeMap::new(),
3193                initiator: "cli",
3194                chat_binding: Some(RunChatBinding {
3195                    chat_id: "chat-1".to_string(),
3196                    lark_app_id: "app-1".to_string(),
3197                }),
3198            },
3199        )
3200        .unwrap();
3201
3202        // First run_loop tick: dispatches the human-gate (creates a wait).
3203        let gate_activity_id = format!("{}::gate::gate", run_id);
3204        {
3205            let mut rt = WorkflowRuntimeContext {
3206                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
3207                def: WorkflowDefinition {
3208                    workflow_id: "flow-cancel-propagate".to_string(),
3209                    version: 1,
3210                    params: None,
3211                    defaults: None,
3212                    nodes: BTreeMap::from([
3213                        (
3214                            "gate".to_string(),
3215                            WorkflowNode::HostExecutor(HostExecutorNode {
3216                                base: NodeBase {
3217                                    description: None,
3218                                    depends: None,
3219                                    human_gate: Some(crate::workflow_definition::HumanGate {
3220                                        stage: "gate".to_string(),
3221                                        prompt: Value::String("approve?".to_string()),
3222                                        approvers: Some(vec!["admin".to_string()]),
3223                                        deadline_ms: None,
3224                                        on_timeout: None,
3225                                    }),
3226                                    retry_policy: None,
3227                                    timeout_ms: None,
3228                                    max_output_bytes: None,
3229                                    output_schema: None,
3230                                    unsafe_allow_ungated: Some(true),
3231                                },
3232                                executor: "beam-schedule".to_string(),
3233                                input: serde_json::json!({"prompt":"approve"}),
3234                            }),
3235                        ),
3236                        (
3237                            "sink".to_string(),
3238                            WorkflowNode::Subagent(SubagentNode {
3239                                base: NodeBase {
3240                                    description: None,
3241                                    depends: Some(vec!["gate".to_string()]),
3242                                    human_gate: None,
3243                                    retry_policy: None,
3244                                    timeout_ms: None,
3245                                    max_output_bytes: None,
3246                                    output_schema: None,
3247                                    unsafe_allow_ungated: None,
3248                                },
3249                                bot: "bot-a".to_string(),
3250                                prompt: Value::String("done".to_string()),
3251                                working_dir: None,
3252                                model_overrides: None,
3253                                tool_policy: None,
3254                            }),
3255                        ),
3256                    ]),
3257                },
3258                runs_base_dir: paths.workflow_runs_dir(),
3259            };
3260            let mut hooks = FakeHooks;
3261            let result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
3262            assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
3263        }
3264
3265        // Write cancelRequested (run).
3266        {
3267            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3268            let _ = crate::request_cancel(
3269                &mut log,
3270                crate::RequestCancelInput {
3271                    target: serde_json::json!({
3272                        "kind": "run",
3273                        "runId": run_id,
3274                    }),
3275                    reason: "test cancel".to_string(),
3276                    by: "tester".to_string(),
3277                },
3278                WorkflowActor::Human,
3279            )
3280            .await
3281            .unwrap();
3282        }
3283
3284        // Second run_loop: should propagate cancel (activityCanceled → runCanceled).
3285        {
3286            let mut rt = WorkflowRuntimeContext {
3287                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
3288                def: WorkflowDefinition {
3289                    workflow_id: "flow-cancel-propagate".to_string(),
3290                    version: 1,
3291                    params: None,
3292                    defaults: None,
3293                    nodes: BTreeMap::from([
3294                        (
3295                            "gate".to_string(),
3296                            WorkflowNode::HostExecutor(HostExecutorNode {
3297                                base: NodeBase {
3298                                    description: None,
3299                                    depends: None,
3300                                    human_gate: Some(crate::workflow_definition::HumanGate {
3301                                        stage: "gate".to_string(),
3302                                        prompt: Value::String("approve?".to_string()),
3303                                        approvers: Some(vec!["admin".to_string()]),
3304                                        deadline_ms: None,
3305                                        on_timeout: None,
3306                                    }),
3307                                    retry_policy: None,
3308                                    timeout_ms: None,
3309                                    max_output_bytes: None,
3310                                    output_schema: None,
3311                                    unsafe_allow_ungated: Some(true),
3312                                },
3313                                executor: "beam-schedule".to_string(),
3314                                input: serde_json::json!({"prompt":"approve"}),
3315                            }),
3316                        ),
3317                        (
3318                            "sink".to_string(),
3319                            WorkflowNode::Subagent(SubagentNode {
3320                                base: NodeBase {
3321                                    description: None,
3322                                    depends: Some(vec!["gate".to_string()]),
3323                                    human_gate: None,
3324                                    retry_policy: None,
3325                                    timeout_ms: None,
3326                                    max_output_bytes: None,
3327                                    output_schema: None,
3328                                    unsafe_allow_ungated: None,
3329                                },
3330                                bot: "bot-a".to_string(),
3331                                prompt: Value::String("done".to_string()),
3332                                working_dir: None,
3333                                model_overrides: None,
3334                                tool_policy: None,
3335                            }),
3336                        ),
3337                    ]),
3338                },
3339                runs_base_dir: paths.workflow_runs_dir(),
3340            };
3341            let mut hooks = FakeHooks;
3342            let result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
3343            assert_eq!(result.reason, RunLoopStopReason::Terminal);
3344        }
3345
3346        // Verify event order: activityCanceled appears before runCanceled.
3347        let log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3348        let events = log.read_all().unwrap();
3349        let has_cancel_requested = events.iter().any(|e| e.event_type == "cancelRequested");
3350        assert!(has_cancel_requested, "should have cancelRequested");
3351
3352        // Find positions of activityCanceled (for the gate) and runCanceled.
3353        let mut activity_canceled_pos: Option<usize> = None;
3354        let mut run_canceled_pos: Option<usize> = None;
3355        for (i, e) in events.iter().enumerate() {
3356            if e.event_type == "activityCanceled"
3357                && e.payload.get("activityId").and_then(Value::as_str) == Some(&gate_activity_id)
3358            {
3359                activity_canceled_pos = Some(i);
3360            }
3361            if e.event_type == "runCanceled" {
3362                run_canceled_pos = Some(i);
3363            }
3364        }
3365
3366        assert!(
3367            activity_canceled_pos.is_some(),
3368            "should have activityCanceled for the gate activity"
3369        );
3370        assert!(run_canceled_pos.is_some(), "should have runCanceled");
3371        assert!(
3372            activity_canceled_pos.unwrap() < run_canceled_pos.unwrap(),
3373            "activityCanceled ({}) must appear before runCanceled ({})",
3374            activity_canceled_pos.unwrap(),
3375            run_canceled_pos.unwrap()
3376        );
3377
3378        let _ = fs::remove_dir_all(&run_dir);
3379    }
3380
3381    /// Verify that cancel propagation is idempotent: running check_pending_cancels
3382    /// again on an already-cancelled run does not write duplicate events.
3383    #[tokio::test]
3384    async fn cancel_propagation_is_idempotent_after_run_is_cancelled() {
3385        let run_dir = temp_run_dir("cancel-idempotent");
3386        let _ = fs::remove_dir_all(&run_dir);
3387        fs::create_dir_all(run_dir.join("blobs")).unwrap();
3388        let paths = crate::BeamPaths::from_root(run_dir.clone());
3389        let run_id = "run-cancel-idempotent";
3390        crate::bootstrap_workflow_run(
3391            &paths,
3392            crate::BootstrapWorkflowRunInput {
3393                run_id,
3394                workflow_json: r#"{"workflowId":"flow-cancel-idem","version":1,"nodes":{"a":{"type":"subagent","bot":"bot-a","prompt":"hello"}}}"#,
3395                expected_workflow_id: Some("flow-cancel-idem"),
3396                params: &BTreeMap::new(),
3397                initiator: "cli",
3398                chat_binding: Some(RunChatBinding {
3399                    chat_id: "chat-1".to_string(),
3400                    lark_app_id: "app-1".to_string(),
3401                }),
3402            },
3403        )
3404        .unwrap();
3405
3406        // Write cancelRequested before any dispatch.
3407        {
3408            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3409            let _ = crate::request_cancel(
3410                &mut log,
3411                crate::RequestCancelInput {
3412                    target: serde_json::json!({
3413                        "kind": "run",
3414                        "runId": run_id,
3415                    }),
3416                    reason: "test cancel".to_string(),
3417                    by: "tester".to_string(),
3418                },
3419                WorkflowActor::Human,
3420            )
3421            .await
3422            .unwrap();
3423        }
3424
3425        // First run_loop: should write runCanceled.
3426        {
3427            let mut rt = WorkflowRuntimeContext {
3428                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
3429                def: WorkflowDefinition {
3430                    workflow_id: "flow-cancel-idem".to_string(),
3431                    version: 1,
3432                    params: None,
3433                    defaults: None,
3434                    nodes: BTreeMap::from([(
3435                        "a".to_string(),
3436                        WorkflowNode::Subagent(SubagentNode {
3437                            base: NodeBase {
3438                                description: None,
3439                                depends: None,
3440                                human_gate: None,
3441                                retry_policy: None,
3442                                timeout_ms: None,
3443                                max_output_bytes: None,
3444                                output_schema: None,
3445                                unsafe_allow_ungated: None,
3446                            },
3447                            bot: "bot-a".to_string(),
3448                            prompt: Value::String("hello".to_string()),
3449                            working_dir: None,
3450                            model_overrides: None,
3451                            tool_policy: None,
3452                        }),
3453                    )]),
3454                },
3455                runs_base_dir: paths.workflow_runs_dir(),
3456            };
3457            let mut hooks = FakeHooks;
3458            let result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
3459            assert_eq!(result.reason, RunLoopStopReason::Terminal);
3460        }
3461
3462        // Second run_loop: should be idempotent (no duplicate runCanceled).
3463        let run_canceled_count_before: usize;
3464        {
3465            let log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3466            let events = log.read_all().unwrap();
3467            run_canceled_count_before = events
3468                .iter()
3469                .filter(|e| e.event_type == "runCanceled")
3470                .count();
3471            assert_eq!(
3472                run_canceled_count_before, 1,
3473                "should have exactly 1 runCanceled after first propagation"
3474            );
3475        }
3476
3477        {
3478            let mut rt = WorkflowRuntimeContext {
3479                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
3480                def: WorkflowDefinition {
3481                    workflow_id: "flow-cancel-idem".to_string(),
3482                    version: 1,
3483                    params: None,
3484                    defaults: None,
3485                    nodes: BTreeMap::from([(
3486                        "a".to_string(),
3487                        WorkflowNode::Subagent(SubagentNode {
3488                            base: NodeBase {
3489                                description: None,
3490                                depends: None,
3491                                human_gate: None,
3492                                retry_policy: None,
3493                                timeout_ms: None,
3494                                max_output_bytes: None,
3495                                output_schema: None,
3496                                unsafe_allow_ungated: None,
3497                            },
3498                            bot: "bot-a".to_string(),
3499                            prompt: Value::String("hello".to_string()),
3500                            working_dir: None,
3501                            model_overrides: None,
3502                            tool_policy: None,
3503                        }),
3504                    )]),
3505                },
3506                runs_base_dir: paths.workflow_runs_dir(),
3507            };
3508            let mut hooks = FakeHooks;
3509            let _ = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
3510        }
3511
3512        let log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3513        let events = log.read_all().unwrap();
3514        let run_canceled_count_after = events
3515            .iter()
3516            .filter(|e| e.event_type == "runCanceled")
3517            .count();
3518        assert_eq!(
3519            run_canceled_count_after, 1,
3520            "second run_loop should not produce duplicate runCanceled"
3521        );
3522
3523        let _ = fs::remove_dir_all(&run_dir);
3524    }
3525
3526    // --- Loop lifecycle action/event tests (Task 8.1) ---
3527
3528    /// Returns a minimal workflow JSON with a single subagent node.
3529    fn min_workflow_json(workflow_id: &str, node_id: &str) -> String {
3530        format!(
3531            r#"{{"workflowId":"{workflow_id}","version":1,"nodes":{{"{node_id}":{{"type":"subagent","bot":"bot-x","prompt":"ok"}}}}}}"#
3532        )
3533    }
3534
3535    #[tokio::test]
3536    async fn start_loop_writes_loop_started_event() {
3537        let run_dir = temp_run_dir("loop-start");
3538        let _ = fs::remove_dir_all(&run_dir);
3539        fs::create_dir_all(run_dir.join("blobs")).unwrap();
3540        let paths = crate::BeamPaths::from_root(run_dir.clone());
3541        let run_id = "run-loop-start";
3542        crate::bootstrap_workflow_run(
3543            &paths,
3544            crate::BootstrapWorkflowRunInput {
3545                run_id,
3546                workflow_json: &min_workflow_json("flow-loop-start", "a"),
3547                expected_workflow_id: Some("flow-loop-start"),
3548                params: &BTreeMap::new(),
3549                initiator: "cli",
3550                chat_binding: None,
3551            },
3552        )
3553        .unwrap();
3554
3555        let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3556        let action = OrchestratorAction::StartLoop {
3557            node_id: "loop-1".to_string(),
3558            max_iterations: 5,
3559        };
3560        start_loop(&mut log, &action).await.unwrap();
3561
3562        let events = log.read_all().unwrap();
3563        let loop_events: Vec<_> = events
3564            .iter()
3565            .filter(|e| e.event_type == "loopStarted")
3566            .collect();
3567        assert_eq!(
3568            loop_events.len(),
3569            1,
3570            "expected exactly one loopStarted event"
3571        );
3572        let ev = loop_events[0];
3573        assert_eq!(ev.payload["loopId"], "loop-1");
3574        assert_eq!(ev.payload["maxIterations"], 5);
3575        assert_eq!(ev.actor, WorkflowActor::Scheduler);
3576
3577        let _ = fs::remove_dir_all(&run_dir);
3578    }
3579
3580    #[tokio::test]
3581    async fn start_loop_iteration_writes_event() {
3582        let run_dir = temp_run_dir("loop-iter-start");
3583        let _ = fs::remove_dir_all(&run_dir);
3584        fs::create_dir_all(run_dir.join("blobs")).unwrap();
3585        let paths = crate::BeamPaths::from_root(run_dir.clone());
3586        let run_id = "run-loop-iter-start";
3587        crate::bootstrap_workflow_run(
3588            &paths,
3589            crate::BootstrapWorkflowRunInput {
3590                run_id,
3591                workflow_json: &min_workflow_json("flow-loop-iter-start", "a"),
3592                expected_workflow_id: Some("flow-loop-iter-start"),
3593                params: &BTreeMap::new(),
3594                initiator: "cli",
3595                chat_binding: None,
3596            },
3597        )
3598        .unwrap();
3599
3600        let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3601        let action = OrchestratorAction::StartLoopIteration {
3602            node_id: "loop-1".to_string(),
3603            iteration: 2,
3604        };
3605        start_loop_iteration(&mut log, &action).await.unwrap();
3606
3607        let events = log.read_all().unwrap();
3608        let loop_events: Vec<_> = events
3609            .iter()
3610            .filter(|e| e.event_type == "loopIterationStarted")
3611            .collect();
3612        assert_eq!(
3613            loop_events.len(),
3614            1,
3615            "expected exactly one loopIterationStarted event"
3616        );
3617        let ev = loop_events[0];
3618        assert_eq!(ev.payload["loopId"], "loop-1");
3619        assert_eq!(ev.payload["iteration"], 2);
3620        assert_eq!(ev.actor, WorkflowActor::Scheduler);
3621
3622        let _ = fs::remove_dir_all(&run_dir);
3623    }
3624
3625    #[tokio::test]
3626    async fn finish_loop_iteration_writes_event() {
3627        let run_dir = temp_run_dir("loop-iter-finish");
3628        let _ = fs::remove_dir_all(&run_dir);
3629        fs::create_dir_all(run_dir.join("blobs")).unwrap();
3630        let paths = crate::BeamPaths::from_root(run_dir.clone());
3631        let run_id = "run-loop-iter-finish";
3632        crate::bootstrap_workflow_run(
3633            &paths,
3634            crate::BootstrapWorkflowRunInput {
3635                run_id,
3636                workflow_json: &min_workflow_json("flow-loop-iter-finish", "a"),
3637                expected_workflow_id: Some("flow-loop-iter-finish"),
3638                params: &BTreeMap::new(),
3639                initiator: "cli",
3640                chat_binding: None,
3641            },
3642        )
3643        .unwrap();
3644
3645        let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3646        let action = OrchestratorAction::FinishLoopIteration {
3647            node_id: "loop-1".to_string(),
3648            iteration: 3,
3649            resolution: "approved".to_string(),
3650            decision_activity_id: Some("run-loop-iter-finish::gate::loop-1".to_string()),
3651            wait_resolved_event_id: None,
3652            by: Some("tester".to_string()),
3653            comment: Some("looks good".to_string()),
3654            timed_out: Some(false),
3655        };
3656        finish_loop_iteration(&mut log, &action).await.unwrap();
3657
3658        let events = log.read_all().unwrap();
3659        let loop_events: Vec<_> = events
3660            .iter()
3661            .filter(|e| e.event_type == "loopIterationFinished")
3662            .collect();
3663        assert_eq!(
3664            loop_events.len(),
3665            1,
3666            "expected exactly one loopIterationFinished event"
3667        );
3668        let ev = loop_events[0];
3669        assert_eq!(ev.payload["loopId"], "loop-1");
3670        assert_eq!(ev.payload["iteration"], 3);
3671        assert_eq!(ev.payload["resolution"], "approved");
3672        assert_eq!(ev.payload["by"], "tester");
3673        assert_eq!(ev.payload["comment"], "looks good");
3674        assert_eq!(ev.payload["timedOut"], false);
3675        assert_eq!(ev.actor, WorkflowActor::Scheduler);
3676
3677        let _ = fs::remove_dir_all(&run_dir);
3678    }
3679
3680    #[tokio::test]
3681    async fn finish_loop_writes_loop_finished_event() {
3682        let run_dir = temp_run_dir("loop-finish");
3683        let _ = fs::remove_dir_all(&run_dir);
3684        fs::create_dir_all(run_dir.join("blobs")).unwrap();
3685        let paths = crate::BeamPaths::from_root(run_dir.clone());
3686        let run_id = "run-loop-finish";
3687        crate::bootstrap_workflow_run(
3688            &paths,
3689            crate::BootstrapWorkflowRunInput {
3690                run_id,
3691                workflow_json: &min_workflow_json("flow-loop-finish", "a"),
3692                expected_workflow_id: Some("flow-loop-finish"),
3693                params: &BTreeMap::new(),
3694                initiator: "cli",
3695                chat_binding: None,
3696            },
3697        )
3698        .unwrap();
3699
3700        let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3701        let output_ref = WorkflowOutputRef {
3702            output_hash: "sha256:abc".to_string(),
3703            output_path: "/tmp/loop-out.json".to_string(),
3704            output_bytes: 10,
3705            output_schema_version: 1,
3706            content_type: Some("application/json".to_string()),
3707        };
3708        let action = OrchestratorAction::FinishLoop {
3709            node_id: "loop-1".to_string(),
3710            final_iteration: 3,
3711            resolution: "approved".to_string(),
3712            output_ref: Some(output_ref.clone()),
3713            error_code: None,
3714            error_class: None,
3715        };
3716        finish_loop(&mut log, &action).await.unwrap();
3717
3718        let events = log.read_all().unwrap();
3719        let loop_events: Vec<_> = events
3720            .iter()
3721            .filter(|e| e.event_type == "loopFinished")
3722            .collect();
3723        assert_eq!(
3724            loop_events.len(),
3725            1,
3726            "expected exactly one loopFinished event"
3727        );
3728        let ev = loop_events[0];
3729        assert_eq!(ev.payload["loopId"], "loop-1");
3730        assert_eq!(ev.payload["finalIteration"], 3);
3731        assert_eq!(ev.payload["resolution"], "approved");
3732        assert_eq!(ev.actor, WorkflowActor::Scheduler);
3733
3734        let _ = fs::remove_dir_all(&run_dir);
3735    }
3736
3737    #[tokio::test]
3738    async fn all_loop_actions_produce_correct_event_sequence() {
3739        // Write a full loop lifecycle and verify event order and types.
3740        let run_dir = temp_run_dir("loop-sequence");
3741        let _ = fs::remove_dir_all(&run_dir);
3742        fs::create_dir_all(run_dir.join("blobs")).unwrap();
3743        let paths = crate::BeamPaths::from_root(run_dir.clone());
3744        let run_id = "run-loop-seq";
3745        crate::bootstrap_workflow_run(
3746            &paths,
3747            crate::BootstrapWorkflowRunInput {
3748                run_id,
3749                workflow_json: &min_workflow_json("flow-loop-seq", "a"),
3750                expected_workflow_id: Some("flow-loop-seq"),
3751                params: &BTreeMap::new(),
3752                initiator: "cli",
3753                chat_binding: None,
3754            },
3755        )
3756        .unwrap();
3757
3758        let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3759
3760        // Start loop
3761        start_loop(
3762            &mut log,
3763            &OrchestratorAction::StartLoop {
3764                node_id: "loop-1".to_string(),
3765                max_iterations: 3,
3766            },
3767        )
3768        .await
3769        .unwrap();
3770
3771        // Iteration 1
3772        start_loop_iteration(
3773            &mut log,
3774            &OrchestratorAction::StartLoopIteration {
3775                node_id: "loop-1".to_string(),
3776                iteration: 1,
3777            },
3778        )
3779        .await
3780        .unwrap();
3781        finish_loop_iteration(
3782            &mut log,
3783            &OrchestratorAction::FinishLoopIteration {
3784                node_id: "loop-1".to_string(),
3785                iteration: 1,
3786                resolution: "approved".to_string(),
3787                decision_activity_id: None,
3788                wait_resolved_event_id: None,
3789                by: None,
3790                comment: None,
3791                timed_out: None,
3792            },
3793        )
3794        .await
3795        .unwrap();
3796
3797        // Iteration 2 (rejected)
3798        start_loop_iteration(
3799            &mut log,
3800            &OrchestratorAction::StartLoopIteration {
3801                node_id: "loop-1".to_string(),
3802                iteration: 2,
3803            },
3804        )
3805        .await
3806        .unwrap();
3807        finish_loop_iteration(
3808            &mut log,
3809            &OrchestratorAction::FinishLoopIteration {
3810                node_id: "loop-1".to_string(),
3811                iteration: 2,
3812                resolution: "rejected".to_string(),
3813                decision_activity_id: Some(format!("{}::gate::loop-1", run_id)),
3814                wait_resolved_event_id: None,
3815                by: Some("reviewer".to_string()),
3816                comment: Some("needs work".to_string()),
3817                timed_out: Some(false),
3818            },
3819        )
3820        .await
3821        .unwrap();
3822
3823        // Finish loop (overall approved after 2 iterations)
3824        let output_ref = WorkflowOutputRef {
3825            output_hash: "sha256:xyz".to_string(),
3826            output_path: "/tmp/xyz.json".to_string(),
3827            output_bytes: 8,
3828            output_schema_version: 1,
3829            content_type: Some("application/json".to_string()),
3830        };
3831        finish_loop(
3832            &mut log,
3833            &OrchestratorAction::FinishLoop {
3834                node_id: "loop-1".to_string(),
3835                final_iteration: 2,
3836                resolution: "approved".to_string(),
3837                output_ref: Some(output_ref.clone()),
3838                error_code: None,
3839                error_class: None,
3840            },
3841        )
3842        .await
3843        .unwrap();
3844
3845        let events = log.read_all().unwrap();
3846        let loop_event_types: Vec<_> = events
3847            .iter()
3848            .filter(|e| e.event_type.starts_with("loop"))
3849            .map(|e| e.event_type.as_str())
3850            .collect();
3851        assert_eq!(
3852            loop_event_types,
3853            vec![
3854                "loopStarted",
3855                "loopIterationStarted",
3856                "loopIterationFinished",
3857                "loopIterationStarted",
3858                "loopIterationFinished",
3859                "loopFinished",
3860            ],
3861            "expected full loop lifecycle events in order"
3862        );
3863
3864        let _ = fs::remove_dir_all(&run_dir);
3865    }
3866
3867    #[tokio::test]
3868    async fn replay_builds_snapshot_loops_from_loop_events() {
3869        // Write loop events directly to the events file, then replay and check
3870        // snapshot.loops.
3871        let run_dir = temp_run_dir("loop-replay");
3872        let _ = fs::remove_dir_all(&run_dir);
3873        fs::create_dir_all(run_dir.join("blobs")).unwrap();
3874        let paths = crate::BeamPaths::from_root(run_dir.clone());
3875        let run_id = "run-loop-replay";
3876        crate::bootstrap_workflow_run(
3877            &paths,
3878            crate::BootstrapWorkflowRunInput {
3879                run_id,
3880                workflow_json: &min_workflow_json("flow-loop-replay", "a"),
3881                expected_workflow_id: Some("flow-loop-replay"),
3882                params: &BTreeMap::new(),
3883                initiator: "cli",
3884                chat_binding: None,
3885            },
3886        )
3887        .unwrap();
3888
3889        // The bootstrap already wrote runCreated + runStarted. Now append loop events directly.
3890        {
3891            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3892            let _ = log
3893                .append(EventDraft {
3894                    event_type: "loopStarted".to_string(),
3895                    actor: WorkflowActor::Scheduler,
3896                    payload: serde_json::json!({
3897                        "loopId": "loop-1",
3898                        "maxIterations": 3,
3899                    }),
3900                    timestamp: None,
3901                    payload_hash: None,
3902                })
3903                .unwrap();
3904            let _ = log
3905                .append(EventDraft {
3906                    event_type: "loopIterationStarted".to_string(),
3907                    actor: WorkflowActor::Scheduler,
3908                    payload: serde_json::json!({
3909                        "loopId": "loop-1",
3910                        "iteration": 1,
3911                    }),
3912                    timestamp: None,
3913                    payload_hash: None,
3914                })
3915                .unwrap();
3916            let _ = log
3917                .append(EventDraft {
3918                    event_type: "loopIterationFinished".to_string(),
3919                    actor: WorkflowActor::Scheduler,
3920                    payload: serde_json::json!({
3921                        "loopId": "loop-1",
3922                        "iteration": 1,
3923                        "resolution": "approved",
3924                    }),
3925                    timestamp: None,
3926                    payload_hash: None,
3927                })
3928                .unwrap();
3929            let _ = log
3930                .append(EventDraft {
3931                    event_type: "loopFinished".to_string(),
3932                    actor: WorkflowActor::Scheduler,
3933                    payload: serde_json::json!({
3934                        "loopId": "loop-1",
3935                        "finalIteration": 1,
3936                        "resolution": "approved",
3937                    }),
3938                    timestamp: None,
3939                    payload_hash: None,
3940                })
3941                .unwrap();
3942        }
3943
3944        // Read snapshot and verify loops
3945        let rt = WorkflowRuntimeContext {
3946            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
3947            def: WorkflowDefinition {
3948                workflow_id: "flow-loop-replay".to_string(),
3949                version: 1,
3950                params: None,
3951                defaults: None,
3952                nodes: BTreeMap::new(),
3953            },
3954            runs_base_dir: paths.workflow_runs_dir(),
3955        };
3956        let snapshot = read_snapshot(&rt).await.unwrap();
3957
3958        let loops = snapshot
3959            .loops
3960            .as_ref()
3961            .expect("snapshot.loops should be Some after loop events");
3962        assert!(
3963            loops.contains_key("loop-1"),
3964            "loop-1 should be in snapshot.loops"
3965        );
3966
3967        let loop_state = loops.get("loop-1").unwrap();
3968        assert_eq!(loop_state.loop_id, "loop-1");
3969        assert_eq!(
3970            loop_state.status,
3971            LoopStatus::Succeeded,
3972            "loop should be Succeeded (approved)"
3973        );
3974        assert_eq!(loop_state.max_iterations, 3);
3975        assert_eq!(loop_state.iteration, 1, "finalIteration should be 1");
3976        assert_eq!(
3977            loop_state.iterations.len(),
3978            1,
3979            "should have 1 iteration recorded"
3980        );
3981
3982        let iteration = &loop_state.iterations[0];
3983        assert_eq!(iteration.iteration, 1);
3984        assert_eq!(iteration.status, LoopIterationStatus::Approved);
3985
3986        let _ = fs::remove_dir_all(&run_dir);
3987    }
3988
3989    #[tokio::test]
3990    async fn replay_loop_failed_sets_status_and_dangling_iteration() {
3991        // Simulate a loop that fails midway: loopStarted → iter1 start/finish
3992        // (approved) → iter2 started → loopFinished with resolution=failed.
3993        // The inflight iteration should be marked Failed.
3994        let run_dir = temp_run_dir("loop-failed-replay");
3995        let _ = fs::remove_dir_all(&run_dir);
3996        fs::create_dir_all(run_dir.join("blobs")).unwrap();
3997        let paths = crate::BeamPaths::from_root(run_dir.clone());
3998        let run_id = "run-loop-failed-replay";
3999        crate::bootstrap_workflow_run(
4000            &paths,
4001            crate::BootstrapWorkflowRunInput {
4002                run_id,
4003                workflow_json: &min_workflow_json("flow-loop-failed-replay", "a"),
4004                expected_workflow_id: Some("flow-loop-failed-replay"),
4005                params: &BTreeMap::new(),
4006                initiator: "cli",
4007                chat_binding: None,
4008            },
4009        )
4010        .unwrap();
4011
4012        {
4013            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4014            let _ = log
4015                .append(EventDraft {
4016                    event_type: "loopStarted".to_string(),
4017                    actor: WorkflowActor::Scheduler,
4018                    payload: serde_json::json!({
4019                        "loopId": "loop-1",
4020                        "maxIterations": 5,
4021                    }),
4022                    timestamp: None,
4023                    payload_hash: None,
4024                })
4025                .unwrap();
4026            let _ = log
4027                .append(EventDraft {
4028                    event_type: "loopIterationStarted".to_string(),
4029                    actor: WorkflowActor::Scheduler,
4030                    payload: serde_json::json!({
4031                        "loopId": "loop-1",
4032                        "iteration": 1,
4033                    }),
4034                    timestamp: None,
4035                    payload_hash: None,
4036                })
4037                .unwrap();
4038            let _ = log
4039                .append(EventDraft {
4040                    event_type: "loopIterationFinished".to_string(),
4041                    actor: WorkflowActor::Scheduler,
4042                    payload: serde_json::json!({
4043                        "loopId": "loop-1",
4044                        "iteration": 1,
4045                        "resolution": "approved",
4046                    }),
4047                    timestamp: None,
4048                    payload_hash: None,
4049                })
4050                .unwrap();
4051            let _ = log
4052                .append(EventDraft {
4053                    event_type: "loopIterationStarted".to_string(),
4054                    actor: WorkflowActor::Scheduler,
4055                    payload: serde_json::json!({
4056                        "loopId": "loop-1",
4057                        "iteration": 2,
4058                    }),
4059                    timestamp: None,
4060                    payload_hash: None,
4061                })
4062                .unwrap();
4063            // Loop finishes with failure while iteration 2 is still running
4064            let _ = log
4065                .append(EventDraft {
4066                    event_type: "loopFinished".to_string(),
4067                    actor: WorkflowActor::Scheduler,
4068                    payload: serde_json::json!({
4069                        "loopId": "loop-1",
4070                        "finalIteration": 2,
4071                        "resolution": "failed",
4072                        "errorCode": "LoopFailedMidIteration",
4073                        "errorClass": "fatal",
4074                    }),
4075                    timestamp: None,
4076                    payload_hash: None,
4077                })
4078                .unwrap();
4079        }
4080
4081        let rt = WorkflowRuntimeContext {
4082            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4083            def: WorkflowDefinition {
4084                workflow_id: "flow-loop-failed-replay".to_string(),
4085                version: 1,
4086                params: None,
4087                defaults: None,
4088                nodes: BTreeMap::new(),
4089            },
4090            runs_base_dir: paths.workflow_runs_dir(),
4091        };
4092        let snapshot = read_snapshot(&rt).await.unwrap();
4093
4094        let loops = snapshot
4095            .loops
4096            .as_ref()
4097            .expect("snapshot.loops should be Some after loop events");
4098        let loop_state = loops.get("loop-1").unwrap();
4099        assert_eq!(
4100            loop_state.status,
4101            LoopStatus::Failed,
4102            "loop should be Failed"
4103        );
4104        assert_eq!(loop_state.error_class.as_deref(), Some("fatal"));
4105        assert_eq!(
4106            loop_state.error_code.as_deref(),
4107            Some("LoopFailedMidIteration")
4108        );
4109        assert_eq!(loop_state.iteration, 2);
4110        assert_eq!(loop_state.iterations.len(), 2);
4111
4112        // Iteration 1 should be Approved
4113        let iter1 = loop_state
4114            .iterations
4115            .iter()
4116            .find(|it| it.iteration == 1)
4117            .unwrap();
4118        assert_eq!(iter1.status, LoopIterationStatus::Approved);
4119
4120        // Iteration 2 should be Failed (inflight → Failed on loop finish failure)
4121        let iter2 = loop_state
4122            .iterations
4123            .iter()
4124            .find(|it| it.iteration == 2)
4125            .unwrap();
4126        assert_eq!(
4127            iter2.status,
4128            LoopIterationStatus::Failed,
4129            "inflight iteration should be Failed"
4130        );
4131
4132        let _ = fs::remove_dir_all(&run_dir);
4133    }
4134
4135    // ── Task 8.2: loop dispatch pass tests ──
4136
4137    /// Build a minimal code-review-loop WorkflowDefinition for testing.
4138    fn code_review_loop_def() -> WorkflowDefinition {
4139        WorkflowDefinition {
4140            workflow_id: "code-review-loop".to_string(),
4141            version: 1,
4142            params: None,
4143            defaults: None,
4144            nodes: BTreeMap::from([
4145                (
4146                    "implement".to_string(),
4147                    WorkflowNode::Subagent(SubagentNode {
4148                        base: NodeBase {
4149                            description: None,
4150                            depends: None,
4151                            human_gate: None,
4152                            retry_policy: None,
4153                            timeout_ms: None,
4154                            max_output_bytes: None,
4155                            output_schema: None,
4156                            unsafe_allow_ungated: None,
4157                        },
4158                        bot: "bot-a".to_string(),
4159                        prompt: Value::String("implement".to_string()),
4160                        working_dir: None,
4161                        model_overrides: None,
4162                        tool_policy: None,
4163                    }),
4164                ),
4165                (
4166                    "review".to_string(),
4167                    WorkflowNode::Subagent(SubagentNode {
4168                        base: NodeBase {
4169                            description: None,
4170                            depends: Some(vec!["implement".to_string()]),
4171                            human_gate: None,
4172                            retry_policy: None,
4173                            timeout_ms: None,
4174                            max_output_bytes: None,
4175                            output_schema: None,
4176                            unsafe_allow_ungated: None,
4177                        },
4178                        bot: "bot-a".to_string(),
4179                        prompt: Value::String("review".to_string()),
4180                        working_dir: None,
4181                        model_overrides: None,
4182                        tool_policy: None,
4183                    }),
4184                ),
4185                (
4186                    "reviewDecision".to_string(),
4187                    WorkflowNode::Decision(DecisionNode {
4188                        base: NodeBase {
4189                            description: None,
4190                            depends: Some(vec!["review".to_string()]),
4191                            human_gate: Some(HumanGate {
4192                                stage: "before".to_string(),
4193                                prompt: Value::String("approve?".to_string()),
4194                                approvers: None,
4195                                deadline_ms: None,
4196                                on_timeout: None,
4197                            }),
4198                            retry_policy: None,
4199                            timeout_ms: None,
4200                            max_output_bytes: None,
4201                            output_schema: None,
4202                            unsafe_allow_ungated: None,
4203                        },
4204                    }),
4205                ),
4206                (
4207                    "review-loop".to_string(),
4208                    WorkflowNode::Loop(LoopNode {
4209                        base: NodeBase {
4210                            description: None,
4211                            depends: None,
4212                            human_gate: None,
4213                            retry_policy: None,
4214                            timeout_ms: None,
4215                            max_output_bytes: None,
4216                            output_schema: None,
4217                            unsafe_allow_ungated: None,
4218                        },
4219                        max_iterations: 3,
4220                        body: vec![
4221                            "implement".to_string(),
4222                            "review".to_string(),
4223                            "reviewDecision".to_string(),
4224                        ],
4225                        terminate: LoopTerminate {
4226                            node: "reviewDecision".to_string(),
4227                            via: "humanGate".to_string(),
4228                        },
4229                        output: Some(LoopOutputProjection {
4230                            from: "implement".to_string(),
4231                        }),
4232                    }),
4233                ),
4234            ]),
4235        }
4236    }
4237
4238    #[tokio::test]
4239    async fn loop_depends_met_produces_start_loop_and_first_iteration() {
4240        // Verify that when a loop node's depends are satisfied, the orchestrator
4241        // emits StartLoop + StartLoopIteration(1).
4242        let run_dir = temp_run_dir("loop-depends");
4243        let _ = fs::remove_dir_all(&run_dir);
4244        fs::create_dir_all(run_dir.join("blobs")).unwrap();
4245        let paths = crate::BeamPaths::from_root(run_dir.clone());
4246        let params: BTreeMap<String, Value> = BTreeMap::new();
4247        let run_id = "run-loop-depends";
4248        crate::bootstrap_workflow_run(
4249            &paths,
4250            crate::BootstrapWorkflowRunInput {
4251                run_id,
4252                workflow_json: r#"{"workflowId":"flow-loop-dep","version":1,"nodes":{"pre":{"type":"subagent","bot":"bot-x","prompt":"setup"},"work":{"type":"subagent","bot":"bot-x","prompt":"work"},"dec":{"type":"decision","depends":["work"],"humanGate":{"stage":"approve","prompt":"continue?"}},"rl":{"type":"loop","maxIterations":3,"body":["work","dec"],"depends":["pre"],"terminate":{"node":"dec","via":"humanGate"},"output":{"from":"work"}}}}"#,
4253                expected_workflow_id: Some("flow-loop-dep"),
4254                params: &params,
4255                initiator: "cli",
4256                chat_binding: None,
4257            },
4258        )
4259        .unwrap();
4260
4261        // First tick: dispatch "pre" (the loop's dependency).
4262        {
4263            let mut rt = WorkflowRuntimeContext {
4264                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4265                def: WorkflowDefinition {
4266                    workflow_id: "flow-loop-dep".to_string(),
4267                    version: 1,
4268                    params: None,
4269                    defaults: None,
4270                    nodes: BTreeMap::from([
4271                        (
4272                            "pre".to_string(),
4273                            WorkflowNode::Subagent(SubagentNode {
4274                                base: NodeBase {
4275                                    description: None,
4276                                    depends: None,
4277                                    human_gate: None,
4278                                    retry_policy: None,
4279                                    timeout_ms: None,
4280                                    max_output_bytes: None,
4281                                    output_schema: None,
4282                                    unsafe_allow_ungated: None,
4283                                },
4284                                bot: "bot-x".to_string(),
4285                                prompt: Value::String("setup".to_string()),
4286                                working_dir: None,
4287                                model_overrides: None,
4288                                tool_policy: None,
4289                            }),
4290                        ),
4291                        (
4292                            "dec".to_string(),
4293                            WorkflowNode::Decision(DecisionNode {
4294                                base: NodeBase {
4295                                    description: None,
4296                                    depends: None,
4297                                    human_gate: None,
4298                                    retry_policy: None,
4299                                    timeout_ms: None,
4300                                    max_output_bytes: None,
4301                                    output_schema: None,
4302                                    unsafe_allow_ungated: None,
4303                                },
4304                            }),
4305                        ),
4306                        (
4307                            "rl".to_string(),
4308                            WorkflowNode::Loop(LoopNode {
4309                                base: NodeBase {
4310                                    description: None,
4311                                    depends: Some(vec!["pre".to_string()]),
4312                                    human_gate: None,
4313                                    retry_policy: None,
4314                                    timeout_ms: None,
4315                                    max_output_bytes: None,
4316                                    output_schema: None,
4317                                    unsafe_allow_ungated: None,
4318                                },
4319                                max_iterations: 3,
4320                                body: vec!["dec".to_string()],
4321                                terminate: LoopTerminate {
4322                                    node: "dec".to_string(),
4323                                    via: "humanGate".to_string(),
4324                                },
4325                                output: Some(LoopOutputProjection {
4326                                    from: "dec".to_string(),
4327                                }),
4328                            }),
4329                        ),
4330                    ]),
4331                },
4332                runs_base_dir: paths.workflow_runs_dir(),
4333            };
4334            let mut hooks = FakeHooks;
4335            let _result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
4336            assert!(
4337                _result.ticks > 0 || matches!(_result.reason, RunLoopStopReason::Terminal),
4338                "expected pre dispatch; reason={:?}",
4339                _result.reason
4340            );
4341        }
4342
4343        // Now "pre" should be succeeded, so the loop can start.
4344        {
4345            let mut rt = WorkflowRuntimeContext {
4346                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4347                def: WorkflowDefinition {
4348                    workflow_id: "flow-loop-dep".to_string(),
4349                    version: 1,
4350                    params: None,
4351                    defaults: None,
4352                    nodes: BTreeMap::from([
4353                        (
4354                            "pre".to_string(),
4355                            WorkflowNode::Subagent(SubagentNode {
4356                                base: NodeBase {
4357                                    description: None,
4358                                    depends: None,
4359                                    human_gate: None,
4360                                    retry_policy: None,
4361                                    timeout_ms: None,
4362                                    max_output_bytes: None,
4363                                    output_schema: None,
4364                                    unsafe_allow_ungated: None,
4365                                },
4366                                bot: "bot-x".to_string(),
4367                                prompt: Value::String("setup".to_string()),
4368                                working_dir: None,
4369                                model_overrides: None,
4370                                tool_policy: None,
4371                            }),
4372                        ),
4373                        (
4374                            "dec".to_string(),
4375                            WorkflowNode::Decision(DecisionNode {
4376                                base: NodeBase {
4377                                    description: None,
4378                                    depends: None,
4379                                    human_gate: None,
4380                                    retry_policy: None,
4381                                    timeout_ms: None,
4382                                    max_output_bytes: None,
4383                                    output_schema: None,
4384                                    unsafe_allow_ungated: None,
4385                                },
4386                            }),
4387                        ),
4388                        (
4389                            "rl".to_string(),
4390                            WorkflowNode::Loop(LoopNode {
4391                                base: NodeBase {
4392                                    description: None,
4393                                    depends: Some(vec!["pre".to_string()]),
4394                                    human_gate: None,
4395                                    retry_policy: None,
4396                                    timeout_ms: None,
4397                                    max_output_bytes: None,
4398                                    output_schema: None,
4399                                    unsafe_allow_ungated: None,
4400                                },
4401                                max_iterations: 3,
4402                                body: vec!["dec".to_string()],
4403                                terminate: LoopTerminate {
4404                                    node: "dec".to_string(),
4405                                    via: "humanGate".to_string(),
4406                                },
4407                                output: Some(LoopOutputProjection {
4408                                    from: "dec".to_string(),
4409                                }),
4410                            }),
4411                        ),
4412                    ]),
4413                },
4414                runs_base_dir: paths.workflow_runs_dir(),
4415            };
4416            let mut hooks = FakeHooks;
4417            let _result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
4418
4419            let events = rt.log.read_all().unwrap();
4420            let loop_started = events.iter().any(|e| {
4421                e.event_type == "loopStarted"
4422                    && e.payload.get("loopId").and_then(Value::as_str) == Some("rl")
4423            });
4424            let iter_started = events.iter().any(|e| {
4425                e.event_type == "loopIterationStarted"
4426                    && e.payload.get("loopId").and_then(Value::as_str) == Some("rl")
4427                    && e.payload.get("iteration").and_then(Value::as_u64) == Some(1)
4428            });
4429            assert!(loop_started, "expected loopStarted event for rl");
4430            assert!(
4431                iter_started,
4432                "expected loopIterationStarted with iteration 1 for rl"
4433            );
4434        }
4435
4436        let _ = fs::remove_dir_all(&run_dir);
4437    }
4438
4439    #[tokio::test]
4440    async fn code_review_loop_reaches_human_gate_wait_with_correct_activity_id() {
4441        // Drive the code-review-loop workflow via run_loop until it reaches an
4442        // open human-gate wait.  Verify the activity-id format.
4443        let run_dir = temp_run_dir("crl-gate");
4444        let _ = fs::remove_dir_all(&run_dir);
4445        fs::create_dir_all(run_dir.join("blobs")).unwrap();
4446        let paths = crate::BeamPaths::from_root(run_dir.clone());
4447        let run_id = "run-crl-gate";
4448        let def = code_review_loop_def();
4449        let workflow_json = serde_json::to_string(&def).unwrap();
4450        crate::bootstrap_workflow_run(
4451            &paths,
4452            crate::BootstrapWorkflowRunInput {
4453                run_id,
4454                workflow_json: &workflow_json,
4455                expected_workflow_id: Some("code-review-loop"),
4456                params: &BTreeMap::new(),
4457                initiator: "cli",
4458                chat_binding: Some(RunChatBinding {
4459                    chat_id: "chat-1".to_string(),
4460                    lark_app_id: "app-1".to_string(),
4461                }),
4462            },
4463        )
4464        .unwrap();
4465
4466        let mut rt = WorkflowRuntimeContext {
4467            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4468            def,
4469            runs_base_dir: paths.workflow_runs_dir(),
4470        };
4471        let mut hooks = FakeHooks;
4472        let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4473
4474        // Should have stopped at AwaitingWait (human gate open for reviewDecision).
4475        assert_eq!(
4476            result.reason,
4477            RunLoopStopReason::AwaitingWait,
4478            "expected AwaitingWait for open human gate"
4479        );
4480
4481        let events = rt.log.read_all().unwrap();
4482
4483        // Verify loop lifecycle events
4484        let loop_started = events.iter().find(|e| e.event_type == "loopStarted");
4485        assert!(loop_started.is_some(), "missing loopStarted");
4486        let iter_started = events.iter().find(|e| {
4487            e.event_type == "loopIterationStarted"
4488                && e.payload.get("iteration") == Some(&Value::Number(1.into()))
4489        });
4490        assert!(iter_started.is_some(), "missing loopIterationStarted(1)");
4491
4492        // Verify activity IDs use the correct loop-scoped format
4493        let implement_work_id = format!("{}::loop::review-loop.1::work::implement", run_id);
4494        let review_work_id = format!("{}::loop::review-loop.1::work::review", run_id);
4495        let decision_gate_id = format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
4496
4497        let has_implement = events.iter().any(|e| {
4498            e.event_type == "attemptCreated"
4499                && e.payload.get("activityId").and_then(Value::as_str) == Some(&implement_work_id)
4500        });
4501        let has_review = events.iter().any(|e| {
4502            e.event_type == "attemptCreated"
4503                && e.payload.get("activityId").and_then(Value::as_str) == Some(&review_work_id)
4504        });
4505        let has_decision_gate = events.iter().any(|e| {
4506            e.event_type == "waitCreated"
4507                && e.payload.get("activityId").and_then(Value::as_str) == Some(&decision_gate_id)
4508        });
4509
4510        assert!(
4511            has_implement,
4512            "missing implement work dispatch: {}",
4513            implement_work_id
4514        );
4515        assert!(
4516            has_review,
4517            "missing review work dispatch: {}",
4518            review_work_id
4519        );
4520        assert!(
4521            has_decision_gate,
4522            "missing reviewDecision gate wait: {}",
4523            decision_gate_id
4524        );
4525
4526        let _ = fs::remove_dir_all(&run_dir);
4527    }
4528
4529    #[tokio::test]
4530    async fn reject_decision_enters_next_iteration() {
4531        // Drive to the gate wait, then write a waitResolved(rejected).
4532        // run_loop should emit FinishLoopIteration(rejected) and StartLoopIteration(2).
4533        let run_dir = temp_run_dir("crl-reject");
4534        let _ = fs::remove_dir_all(&run_dir);
4535        fs::create_dir_all(run_dir.join("blobs")).unwrap();
4536        let paths = crate::BeamPaths::from_root(run_dir.clone());
4537        let run_id = "run-crl-reject";
4538        let def = code_review_loop_def();
4539        let workflow_json = serde_json::to_string(&def).unwrap();
4540        crate::bootstrap_workflow_run(
4541            &paths,
4542            crate::BootstrapWorkflowRunInput {
4543                run_id,
4544                workflow_json: &workflow_json,
4545                expected_workflow_id: Some("code-review-loop"),
4546                params: &BTreeMap::new(),
4547                initiator: "cli",
4548                chat_binding: Some(RunChatBinding {
4549                    chat_id: "chat-1".to_string(),
4550                    lark_app_id: "app-1".to_string(),
4551                }),
4552            },
4553        )
4554        .unwrap();
4555
4556        let decision_gate_id = format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
4557
4558        // First: drive to AwaitingWait.
4559        {
4560            let mut rt = WorkflowRuntimeContext {
4561                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4562                def: def.clone(),
4563                runs_base_dir: paths.workflow_runs_dir(),
4564            };
4565            let mut hooks = FakeHooks;
4566            let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4567            assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4568        }
4569
4570        // Write waitResolved (rejected).
4571        {
4572            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4573            let _ = log
4574                .append(EventDraft {
4575                    event_type: "waitResolved".to_string(),
4576                    actor: WorkflowActor::Human,
4577                    payload: serde_json::json!({
4578                        "activityId": &decision_gate_id,
4579                        "resolution": "rejected",
4580                        "by": "reviewer",
4581                        "comment": "needs work",
4582                    }),
4583                    timestamp: None,
4584                    payload_hash: None,
4585                })
4586                .unwrap();
4587        }
4588
4589        // Second: run_loop again — should handle the rejection.
4590        {
4591            let mut rt = WorkflowRuntimeContext {
4592                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4593                def,
4594                runs_base_dir: paths.workflow_runs_dir(),
4595            };
4596            let mut hooks = FakeHooks;
4597            let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4598
4599            let events = rt.log.read_all().unwrap();
4600
4601            // Verify FinishLoopIteration(rejected) for iteration 1.
4602            let iter1_finished = events.iter().any(|e| {
4603                e.event_type == "loopIterationFinished"
4604                    && e.payload.get("iteration").and_then(Value::as_u64) == Some(1)
4605                    && e.payload.get("resolution").and_then(Value::as_str) == Some("rejected")
4606            });
4607            assert!(
4608                iter1_finished,
4609                "expected loopIterationFinished with resolution=rejected for iteration 1"
4610            );
4611
4612            // Verify StartLoopIteration(2).
4613            let iter2_started = events.iter().any(|e| {
4614                e.event_type == "loopIterationStarted"
4615                    && e.payload.get("iteration").and_then(Value::as_u64) == Some(2)
4616            });
4617            assert!(
4618                iter2_started,
4619                "expected loopIterationStarted with iteration=2"
4620            );
4621
4622            // Verify iteration 2 dispatches work (body nodes run again).
4623            let implement_work_v2 = format!("{}::loop::review-loop.2::work::implement", run_id);
4624            let has_iter2_implement = events.iter().any(|e| {
4625                e.event_type == "attemptCreated"
4626                    && e.payload.get("activityId").and_then(Value::as_str)
4627                        == Some(&implement_work_v2)
4628            });
4629            assert!(
4630                has_iter2_implement,
4631                "expected iteration 2 to dispatch implement work: {}",
4632                implement_work_v2
4633            );
4634        }
4635
4636        let _ = fs::remove_dir_all(&run_dir);
4637    }
4638
4639    #[tokio::test]
4640    async fn approve_decision_finishes_loop_and_run_succeeds() {
4641        // Drive to gate wait, write waitResolved(approved), run_loop emits
4642        // FinishLoop(approved) and the run succeeds.
4643        let run_dir = temp_run_dir("crl-approve");
4644        let _ = fs::remove_dir_all(&run_dir);
4645        fs::create_dir_all(run_dir.join("blobs")).unwrap();
4646        let paths = crate::BeamPaths::from_root(run_dir.clone());
4647        let run_id = "run-crl-approve";
4648        let def = code_review_loop_def();
4649        let workflow_json = serde_json::to_string(&def).unwrap();
4650        crate::bootstrap_workflow_run(
4651            &paths,
4652            crate::BootstrapWorkflowRunInput {
4653                run_id,
4654                workflow_json: &workflow_json,
4655                expected_workflow_id: Some("code-review-loop"),
4656                params: &BTreeMap::new(),
4657                initiator: "cli",
4658                chat_binding: Some(RunChatBinding {
4659                    chat_id: "chat-1".to_string(),
4660                    lark_app_id: "app-1".to_string(),
4661                }),
4662            },
4663        )
4664        .unwrap();
4665
4666        let decision_gate_id = format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
4667
4668        // First: drive to AwaitingWait.
4669        {
4670            let mut rt = WorkflowRuntimeContext {
4671                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4672                def: def.clone(),
4673                runs_base_dir: paths.workflow_runs_dir(),
4674            };
4675            let mut hooks = FakeHooks;
4676            let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4677            assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4678        }
4679
4680        // Write waitResolved (approved).
4681        {
4682            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4683            let _ = log
4684                .append(EventDraft {
4685                    event_type: "waitResolved".to_string(),
4686                    actor: WorkflowActor::Human,
4687                    payload: serde_json::json!({
4688                        "activityId": &decision_gate_id,
4689                        "resolution": "approved",
4690                        "by": "reviewer",
4691                        "comment": "lgtm",
4692                    }),
4693                    timestamp: None,
4694                    payload_hash: None,
4695                })
4696                .unwrap();
4697        }
4698
4699        // Second: run_loop again — should approve and finish.
4700        {
4701            let mut rt = WorkflowRuntimeContext {
4702                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4703                def,
4704                runs_base_dir: paths.workflow_runs_dir(),
4705            };
4706            let mut hooks = FakeHooks;
4707            let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4708
4709            let events = rt.log.read_all().unwrap();
4710
4711            // Verify loopFinished with approved resolution.
4712            let loop_finished = events.iter().any(|e| {
4713                e.event_type == "loopFinished"
4714                    && e.payload.get("loopId").and_then(Value::as_str) == Some("review-loop")
4715                    && e.payload.get("resolution").and_then(Value::as_str) == Some("approved")
4716            });
4717            assert!(
4718                loop_finished,
4719                "expected loopFinished with resolution=approved"
4720            );
4721
4722            // Verify runSucceeded (since loop is the only top-level node).
4723            let run_succeeded = events.iter().any(|e| e.event_type == "runSucceeded");
4724            assert!(run_succeeded, "expected run to succeed after loop approval");
4725
4726            // Verify loop output is produced (from implement node).
4727            let snap = read_snapshot(&rt).await.unwrap();
4728            let loop_output_key = format!("{}::work::review-loop", run_id);
4729            assert!(
4730                snap.outputs.contains_key(&loop_output_key),
4731                "expected loop output under {}",
4732                loop_output_key
4733            );
4734        }
4735
4736        let _ = fs::remove_dir_all(&run_dir);
4737    }
4738
4739    /// Hook that fails every subagent call — used to test body failure.
4740    #[derive(Clone)]
4741    struct FailingBodyHooks;
4742
4743    #[async_trait]
4744    impl WorkflowExecutionHooks for FailingBodyHooks {
4745        async fn execute_subagent(
4746            &mut self,
4747            _ctx: WorkflowDispatchRun<'_>,
4748            _node: &SubagentNode,
4749            _resolved_prompt: String,
4750        ) -> Result<WorkflowDispatchOutcome> {
4751            Ok(WorkflowDispatchOutcome::Failed {
4752                error_code: "TestFailure".to_string(),
4753                error_class: "fatal".to_string(),
4754                error_message: "simulated body failure".to_string(),
4755                session: None,
4756            })
4757        }
4758
4759        async fn execute_host_executor(
4760            &mut self,
4761            _ctx: WorkflowDispatchRun<'_>,
4762            _node: &HostExecutorNode,
4763            _resolved_input: Value,
4764        ) -> Result<WorkflowDispatchOutcome> {
4765            Ok(WorkflowDispatchOutcome::Failed {
4766                error_code: "TestFailure".to_string(),
4767                error_class: "fatal".to_string(),
4768                error_message: "simulated body failure".to_string(),
4769                session: None,
4770            })
4771        }
4772    }
4773
4774    #[tokio::test]
4775    async fn body_failure_causes_loop_failed() {
4776        // When a body node fails (subagent returns Failed), the loop should
4777        // immediately fail with FinishLoop(failed).
4778        let run_dir = temp_run_dir("crl-body-fail");
4779        let _ = fs::remove_dir_all(&run_dir);
4780        fs::create_dir_all(run_dir.join("blobs")).unwrap();
4781        let paths = crate::BeamPaths::from_root(run_dir.clone());
4782        let run_id = "run-crl-body-fail";
4783        let def = code_review_loop_def();
4784        let workflow_json = serde_json::to_string(&def).unwrap();
4785        crate::bootstrap_workflow_run(
4786            &paths,
4787            crate::BootstrapWorkflowRunInput {
4788                run_id,
4789                workflow_json: &workflow_json,
4790                expected_workflow_id: Some("code-review-loop"),
4791                params: &BTreeMap::new(),
4792                initiator: "cli",
4793                chat_binding: Some(RunChatBinding {
4794                    chat_id: "chat-1".to_string(),
4795                    lark_app_id: "app-1".to_string(),
4796                }),
4797            },
4798        )
4799        .unwrap();
4800
4801        let mut rt = WorkflowRuntimeContext {
4802            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4803            def,
4804            runs_base_dir: paths.workflow_runs_dir(),
4805        };
4806        let mut hooks = FailingBodyHooks;
4807        let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4808
4809        let events = rt.log.read_all().unwrap();
4810
4811        // Verify loopStarted and loopIterationStarted(1) were written.
4812        let loop_started = events.iter().any(|e| e.event_type == "loopStarted");
4813        assert!(loop_started, "expected loopStarted");
4814
4815        let iter_started = events.iter().any(|e| {
4816            e.event_type == "loopIterationStarted"
4817                && e.payload.get("iteration").and_then(Value::as_u64) == Some(1)
4818        });
4819        assert!(iter_started, "expected loopIterationStarted(1)");
4820
4821        // Body node (implement) should have failed.
4822        let implement_work_id = format!("{}::loop::review-loop.1::work::implement", run_id);
4823        let has_implement_fail = events.iter().any(|e| {
4824            e.event_type == "activityFailed"
4825                && e.payload.get("activityId").and_then(Value::as_str) == Some(&implement_work_id)
4826        });
4827        assert!(
4828            has_implement_fail,
4829            "expected implement work to fail: {}",
4830            implement_work_id
4831        );
4832
4833        // Loop should have failed.
4834        let loop_failed = events.iter().any(|e| {
4835            e.event_type == "loopFinished"
4836                && e.payload.get("loopId").and_then(Value::as_str) == Some("review-loop")
4837                && e.payload.get("resolution").and_then(Value::as_str) == Some("failed")
4838        });
4839        assert!(
4840            loop_failed,
4841            "expected loopFinished with resolution=failed after body failure"
4842        );
4843
4844        // Run should have failed (loop failed → run failed).
4845        assert!(
4846            matches!(result.reason, RunLoopStopReason::Terminal),
4847            "expected terminal; reason={:?}",
4848            result.reason
4849        );
4850        let run_failed = events.iter().any(|e| e.event_type == "runFailed");
4851        assert!(run_failed, "expected run to fail after loop body failure");
4852
4853        let _ = fs::remove_dir_all(&run_dir);
4854    }
4855
4856    #[tokio::test]
4857    async fn max_iterations_reject_causes_loop_failed() {
4858        // Reject at the last allowed iteration (maxIterations=3, iteration 3).
4859        // Should produce FinishLoop(failed) with MaxIterationsReached.
4860        let run_dir = temp_run_dir("crl-maxiter");
4861        let _ = fs::remove_dir_all(&run_dir);
4862        fs::create_dir_all(run_dir.join("blobs")).unwrap();
4863        let paths = crate::BeamPaths::from_root(run_dir.clone());
4864        let run_id = "run-crl-maxiter";
4865        let def = code_review_loop_def();
4866        let workflow_json = serde_json::to_string(&def).unwrap();
4867        crate::bootstrap_workflow_run(
4868            &paths,
4869            crate::BootstrapWorkflowRunInput {
4870                run_id,
4871                workflow_json: &workflow_json,
4872                expected_workflow_id: Some("code-review-loop"),
4873                params: &BTreeMap::new(),
4874                initiator: "cli",
4875                chat_binding: Some(RunChatBinding {
4876                    chat_id: "chat-1".to_string(),
4877                    lark_app_id: "app-1".to_string(),
4878                }),
4879            },
4880        )
4881        .unwrap();
4882
4883        // Helper to drive to an open gate wait at a specific iteration.
4884        // We'll go through iterations 1→2→3, rejecting each time until the
4885        // final one causes failure.
4886
4887        let iter1_gate_id = format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
4888
4889        // Iteration 1: drive to gate, reject.
4890        {
4891            let mut rt = WorkflowRuntimeContext {
4892                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4893                def: def.clone(),
4894                runs_base_dir: paths.workflow_runs_dir(),
4895            };
4896            let mut hooks = FakeHooks;
4897            let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4898            assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4899        }
4900        // Reject iteration 1.
4901        {
4902            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4903            let _ = log
4904                .append(EventDraft {
4905                    event_type: "waitResolved".to_string(),
4906                    actor: WorkflowActor::Human,
4907                    payload: serde_json::json!({
4908                        "activityId": &iter1_gate_id,
4909                        "resolution": "rejected",
4910                        "by": "reviewer",
4911                        "comment": "redo",
4912                    }),
4913                    timestamp: None,
4914                    payload_hash: None,
4915                })
4916                .unwrap();
4917        }
4918        // Process rejection → iteration 2 should start.
4919        {
4920            let mut rt = WorkflowRuntimeContext {
4921                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4922                def: def.clone(),
4923                runs_base_dir: paths.workflow_runs_dir(),
4924            };
4925            let mut hooks = FakeHooks;
4926            let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4927
4928            let events = rt.log.read_all().unwrap();
4929            let iter2_started = events.iter().any(|e| {
4930                e.event_type == "loopIterationStarted"
4931                    && e.payload.get("iteration").and_then(Value::as_u64) == Some(2)
4932            });
4933            assert!(iter2_started, "expected iteration 2 started");
4934        }
4935
4936        // Iteration 2: drive to gate, reject.
4937        {
4938            let mut rt = WorkflowRuntimeContext {
4939                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4940                def: def.clone(),
4941                runs_base_dir: paths.workflow_runs_dir(),
4942            };
4943            let mut hooks = FakeHooks;
4944            let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4945            assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4946        }
4947        // Reject iteration 2.
4948        {
4949            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4950            let iter2_gate_id = format!("{}::loop::review-loop.2::gate::reviewDecision", run_id);
4951            let _ = log
4952                .append(EventDraft {
4953                    event_type: "waitResolved".to_string(),
4954                    actor: WorkflowActor::Human,
4955                    payload: serde_json::json!({
4956                        "activityId": &iter2_gate_id,
4957                        "resolution": "rejected",
4958                        "by": "reviewer",
4959                        "comment": "still no",
4960                    }),
4961                    timestamp: None,
4962                    payload_hash: None,
4963                })
4964                .unwrap();
4965        }
4966        // Process → iteration 3.
4967        {
4968            let mut rt = WorkflowRuntimeContext {
4969                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4970                def: def.clone(),
4971                runs_base_dir: paths.workflow_runs_dir(),
4972            };
4973            let mut hooks = FakeHooks;
4974            let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4975
4976            let events = rt.log.read_all().unwrap();
4977            let iter3_started = events.iter().any(|e| {
4978                e.event_type == "loopIterationStarted"
4979                    && e.payload.get("iteration").and_then(Value::as_u64) == Some(3)
4980            });
4981            assert!(iter3_started, "expected iteration 3 started");
4982        }
4983
4984        // Iteration 3: drive to gate, reject (max iterations hit).
4985        {
4986            let mut rt = WorkflowRuntimeContext {
4987                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4988                def: def.clone(),
4989                runs_base_dir: paths.workflow_runs_dir(),
4990            };
4991            let mut hooks = FakeHooks;
4992            let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4993            assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4994        }
4995        // Reject iteration 3 (final iteration) → loop should fail.
4996        {
4997            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4998            let iter3_gate_id = format!("{}::loop::review-loop.3::gate::reviewDecision", run_id);
4999            let _ = log
5000                .append(EventDraft {
5001                    event_type: "waitResolved".to_string(),
5002                    actor: WorkflowActor::Human,
5003                    payload: serde_json::json!({
5004                        "activityId": &iter3_gate_id,
5005                        "resolution": "rejected",
5006                        "by": "reviewer",
5007                        "comment": "still no",
5008                    }),
5009                    timestamp: None,
5010                    payload_hash: None,
5011                })
5012                .unwrap();
5013        }
5014        // Process → loop and run should fail.
5015        {
5016            let mut rt = WorkflowRuntimeContext {
5017                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5018                def,
5019                runs_base_dir: paths.workflow_runs_dir(),
5020            };
5021            let mut hooks = FakeHooks;
5022            let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5023
5024            let events = rt.log.read_all().unwrap();
5025
5026            // loopFinished with failed.
5027            let loop_failed = events.iter().any(|e| {
5028                e.event_type == "loopFinished"
5029                    && e.payload.get("loopId").and_then(Value::as_str) == Some("review-loop")
5030                    && e.payload.get("resolution").and_then(Value::as_str) == Some("failed")
5031                    && e.payload.get("errorCode").and_then(Value::as_str)
5032                        == Some("MaxIterationsReached")
5033            });
5034            assert!(
5035                loop_failed,
5036                "expected loopFinished with failed/MaxIterationsReached"
5037            );
5038
5039            assert!(
5040                matches!(result.reason, RunLoopStopReason::Terminal),
5041                "expected terminal; reason={:?}",
5042                result.reason
5043            );
5044            let run_failed = events.iter().any(|e| e.event_type == "runFailed");
5045            assert!(
5046                run_failed,
5047                "expected run to fail when max iterations reached"
5048            );
5049        }
5050
5051        let _ = fs::remove_dir_all(&run_dir);
5052    }
5053
5054    // ── Task 8.2: tests using the real code-review-loop.workflow.json ──
5055
5056    /// Like FakeHooks but returns a JSON object with common workflow output
5057    /// fields so that $ref bindings (implement.output.code,
5058    /// review.output.preview) work.
5059    #[derive(Clone)]
5060    struct RichFakeHooks;
5061
5062    #[async_trait]
5063    impl WorkflowExecutionHooks for RichFakeHooks {
5064        async fn execute_subagent(
5065            &mut self,
5066            _ctx: WorkflowDispatchRun<'_>,
5067            _node: &SubagentNode,
5068            _resolved_prompt: String,
5069        ) -> Result<WorkflowDispatchOutcome> {
5070            Ok(WorkflowDispatchOutcome::Succeeded {
5071                output: serde_json::json!({"code": "mock-code", "summary": "mock-summary", "preview": "looks good"}),
5072                session: None,
5073            })
5074        }
5075
5076        async fn execute_host_executor(
5077            &mut self,
5078            _ctx: WorkflowDispatchRun<'_>,
5079            _node: &HostExecutorNode,
5080            resolved_input: Value,
5081        ) -> Result<WorkflowDispatchOutcome> {
5082            Ok(WorkflowDispatchOutcome::Succeeded {
5083                output: resolved_input,
5084                session: None,
5085            })
5086        }
5087    }
5088
5089    /// Hook that records resolved subagent prompts for verification.
5090    #[derive(Clone)]
5091    struct RecordingHooks {
5092        prompts: Arc<Mutex<Vec<(String, String)>>>, // (node_id, resolved_prompt)
5093    }
5094
5095    #[async_trait]
5096    impl WorkflowExecutionHooks for RecordingHooks {
5097        async fn execute_subagent(
5098            &mut self,
5099            _ctx: WorkflowDispatchRun<'_>,
5100            _node: &SubagentNode,
5101            resolved_prompt: String,
5102        ) -> Result<WorkflowDispatchOutcome> {
5103            self.prompts
5104                .lock()
5105                .await
5106                .push((_ctx.node_id.to_string(), resolved_prompt.clone()));
5107            // Produce a JSON object with common workflow output fields so
5108            // that $ref bindings (implement.output.code,
5109            // review.output.preview) resolve correctly.
5110            let out = serde_json::json!({"code": "mock-code", "summary": "mock-summary", "preview": "looks good"});
5111            Ok(WorkflowDispatchOutcome::Succeeded {
5112                output: out,
5113                session: None,
5114            })
5115        }
5116
5117        async fn execute_host_executor(
5118            &mut self,
5119            _ctx: WorkflowDispatchRun<'_>,
5120            _node: &HostExecutorNode,
5121            resolved_input: Value,
5122        ) -> Result<WorkflowDispatchOutcome> {
5123            Ok(WorkflowDispatchOutcome::Succeeded {
5124                output: resolved_input,
5125                session: None,
5126            })
5127        }
5128    }
5129
5130    #[tokio::test]
5131    async fn real_code_review_loop_iter1_reaches_awaiting_wait() {
5132        // Load the real workflow JSON, bootstrap with task param,
5133        // and verify iteration 1 reaches AwaitingWait without errors
5134        // from ${reviewDecision.previous.comment}.
5135        let raw = include_str!("../../../workflows/code-review-loop.workflow.json");
5136        let def = crate::parse_workflow_definition(raw).expect("parse real code-review-loop");
5137        let workflow_json = serde_json::to_string(&def).unwrap();
5138
5139        let run_dir = temp_run_dir("real-crl-iter1");
5140        let _ = fs::remove_dir_all(&run_dir);
5141        fs::create_dir_all(run_dir.join("blobs")).unwrap();
5142        let paths = crate::BeamPaths::from_root(run_dir.clone());
5143        let run_id = "run-real-crl-1";
5144        let params: BTreeMap<String, Value> = BTreeMap::from([(
5145            String::from("task"),
5146            Value::String("add CLI echo command".to_string()),
5147        )]);
5148        crate::bootstrap_workflow_run(
5149            &paths,
5150            crate::BootstrapWorkflowRunInput {
5151                run_id,
5152                workflow_json: &workflow_json,
5153                expected_workflow_id: Some("code-review-loop"),
5154                params: &params,
5155                initiator: "cli",
5156                chat_binding: Some(RunChatBinding {
5157                    chat_id: "chat-1".to_string(),
5158                    lark_app_id: "app-1".to_string(),
5159                }),
5160            },
5161        )
5162        .unwrap();
5163
5164        let prompts = Arc::new(Mutex::new(Vec::new()));
5165        let mut hooks = RecordingHooks {
5166            prompts: prompts.clone(),
5167        };
5168
5169        let mut rt = WorkflowRuntimeContext {
5170            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5171            def,
5172            runs_base_dir: paths.workflow_runs_dir(),
5173        };
5174        let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5175
5176        assert_eq!(
5177            result.reason,
5178            RunLoopStopReason::AwaitingWait,
5179            "expected AwaitingWait for open human gate"
5180        );
5181
5182        // Verify implement's resolved prompt contains the task and does NOT
5183        // error on ${reviewDecision.previous.comment} (iteration 1 → empty).
5184        let recorded = prompts.lock().await;
5185        let implement_prompt = recorded
5186            .iter()
5187            .find(|(node_id, _)| node_id == "implement")
5188            .map(|(_, p)| p.clone())
5189            .expect("implement must have been dispatched");
5190        eprintln!("implement prompt iter1: {implement_prompt}");
5191        assert!(
5192            implement_prompt.contains("add CLI echo command"),
5193            "implement prompt should contain the task param"
5194        );
5195        // In iteration 1, .previous.comment resolves to empty string.
5196        assert!(
5197            !implement_prompt.contains("ERROR")
5198                && !implement_prompt.contains("no previous iteration"),
5199            "implement prompt should not contain binding errors: {implement_prompt}"
5200        );
5201
5202        // Verify loop-scoped activity IDs exist
5203        let events = rt.log.read_all().unwrap();
5204        let decision_gate_id = format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
5205        let has_gate_wait = events.iter().any(|e| {
5206            e.event_type == "waitCreated"
5207                && e.payload.get("activityId").and_then(Value::as_str) == Some(&decision_gate_id)
5208        });
5209        assert!(has_gate_wait, "expected gate wait for {}", decision_gate_id);
5210
5211        let _ = fs::remove_dir_all(&run_dir);
5212    }
5213
5214    #[tokio::test]
5215    async fn real_code_review_loop_reject_enters_iter2_with_comment() {
5216        // Iteration 1: drive to gate, reject with comment.
5217        // Iteration 2: verify implement's resolved prompt contains the
5218        // reject comment from the previous iteration.
5219        let raw = include_str!("../../../workflows/code-review-loop.workflow.json");
5220        let def = crate::parse_workflow_definition(raw).expect("parse real code-review-loop");
5221        let workflow_json = serde_json::to_string(&def).unwrap();
5222
5223        let run_dir = temp_run_dir("real-crl-reject");
5224        let _ = fs::remove_dir_all(&run_dir);
5225        fs::create_dir_all(run_dir.join("blobs")).unwrap();
5226        let paths = crate::BeamPaths::from_root(run_dir.clone());
5227        let run_id = "run-real-crl-rej";
5228        let params: BTreeMap<String, Value> =
5229            BTreeMap::from([(String::from("task"), Value::String("add test".to_string()))]);
5230        crate::bootstrap_workflow_run(
5231            &paths,
5232            crate::BootstrapWorkflowRunInput {
5233                run_id,
5234                workflow_json: &workflow_json,
5235                expected_workflow_id: Some("code-review-loop"),
5236                params: &params,
5237                initiator: "cli",
5238                chat_binding: Some(RunChatBinding {
5239                    chat_id: "chat-1".to_string(),
5240                    lark_app_id: "app-1".to_string(),
5241                }),
5242            },
5243        )
5244        .unwrap();
5245
5246        let prompts = Arc::new(Mutex::new(Vec::new()));
5247
5248        // Iteration 1: drive to AwaitingWait.
5249        {
5250            let mut hooks = RecordingHooks {
5251                prompts: prompts.clone(),
5252            };
5253            let mut rt = WorkflowRuntimeContext {
5254                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5255                def: def.clone(),
5256                runs_base_dir: paths.workflow_runs_dir(),
5257            };
5258            let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5259            assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
5260        }
5261
5262        // Reject iteration 1 with a comment.
5263        let decision_gate_id = format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
5264        {
5265            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
5266            let _ = log
5267                .append(EventDraft {
5268                    event_type: "waitResolved".to_string(),
5269                    actor: WorkflowActor::Human,
5270                    payload: serde_json::json!({
5271                        "activityId": &decision_gate_id,
5272                        "resolution": "rejected",
5273                        "by": "reviewer",
5274                        "comment": "needs more tests",
5275                    }),
5276                    timestamp: None,
5277                    payload_hash: None,
5278                })
5279                .unwrap();
5280        }
5281
5282        // Process rejection → iteration 2 should start.
5283        {
5284            let mut hooks = RecordingHooks {
5285                prompts: prompts.clone(),
5286            };
5287            let mut rt = WorkflowRuntimeContext {
5288                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5289                def: def.clone(),
5290                runs_base_dir: paths.workflow_runs_dir(),
5291            };
5292            let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5293
5294            // Verify loopIterationFinished metadata
5295            let events = rt.log.read_all().unwrap();
5296            let iter1_finished = events.iter().find(|e| {
5297                e.event_type == "loopIterationFinished"
5298                    && e.payload.get("iteration").and_then(Value::as_u64) == Some(1)
5299            });
5300            assert!(
5301                iter1_finished.is_some(),
5302                "expected loopIterationFinished for iteration 1"
5303            );
5304            let payload = &iter1_finished.unwrap().payload;
5305            assert_eq!(
5306                payload.get("resolution").and_then(Value::as_str),
5307                Some("rejected"),
5308                "resolution should be rejected"
5309            );
5310            assert_eq!(
5311                payload.get("by").and_then(Value::as_str),
5312                Some("reviewer"),
5313                "by should be reviewer"
5314            );
5315            assert_eq!(
5316                payload.get("comment").and_then(Value::as_str),
5317                Some("needs more tests"),
5318                "comment should be preserved"
5319            );
5320            assert_eq!(
5321                payload.get("decisionActivityId").and_then(Value::as_str),
5322                Some(decision_gate_id.as_str()),
5323                "decisionActivityId should be the gate id"
5324            );
5325
5326            let iter2_started = events.iter().any(|e| {
5327                e.event_type == "loopIterationStarted"
5328                    && e.payload.get("iteration").and_then(Value::as_u64) == Some(2)
5329            });
5330            assert!(iter2_started, "expected iteration 2 started");
5331        }
5332
5333        // Now verify iteration 2 implement prompt contains the reject comment.
5334        // The runtime already dispatched implement in iteration 2 during the
5335        // above run_loop call.
5336        let recorded = prompts.lock().await;
5337        let iter2_implement = recorded
5338            .iter()
5339            .filter(|(node_id, _)| node_id == "implement")
5340            .last()
5341            .map(|(_, p)| p.clone())
5342            .expect("implement iter2 must have been dispatched");
5343        eprintln!("implement prompt iter2: {iter2_implement}");
5344        assert!(
5345            iter2_implement.contains("needs more tests"),
5346            "iter2 implement prompt should contain reject comment 'needs more tests': {iter2_implement}"
5347        );
5348
5349        let _ = fs::remove_dir_all(&run_dir);
5350    }
5351
5352    #[tokio::test]
5353    async fn real_code_review_loop_approve_succeeds() {
5354        // Load real workflow, drive to gate, approve, verify loop/run succeeded.
5355        let raw = include_str!("../../../workflows/code-review-loop.workflow.json");
5356        let def = crate::parse_workflow_definition(raw).expect("parse real code-review-loop");
5357        let workflow_json = serde_json::to_string(&def).unwrap();
5358
5359        let run_dir = temp_run_dir("real-crl-approve");
5360        let _ = fs::remove_dir_all(&run_dir);
5361        fs::create_dir_all(run_dir.join("blobs")).unwrap();
5362        let paths = crate::BeamPaths::from_root(run_dir.clone());
5363        let run_id = "run-real-crl-app";
5364        let params: BTreeMap<String, Value> = BTreeMap::from([(
5365            String::from("task"),
5366            Value::String("add feature".to_string()),
5367        )]);
5368        crate::bootstrap_workflow_run(
5369            &paths,
5370            crate::BootstrapWorkflowRunInput {
5371                run_id,
5372                workflow_json: &workflow_json,
5373                expected_workflow_id: Some("code-review-loop"),
5374                params: &params,
5375                initiator: "cli",
5376                chat_binding: Some(RunChatBinding {
5377                    chat_id: "chat-1".to_string(),
5378                    lark_app_id: "app-1".to_string(),
5379                }),
5380            },
5381        )
5382        .unwrap();
5383
5384        // Iteration 1: drive to AwaitingWait.
5385        {
5386            let mut hooks = RichFakeHooks;
5387            let mut rt = WorkflowRuntimeContext {
5388                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5389                def: def.clone(),
5390                runs_base_dir: paths.workflow_runs_dir(),
5391            };
5392            let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5393            assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
5394        }
5395
5396        // Approve.
5397        let decision_gate_id = format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
5398        {
5399            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
5400            let _ = log
5401                .append(EventDraft {
5402                    event_type: "waitResolved".to_string(),
5403                    actor: WorkflowActor::Human,
5404                    payload: serde_json::json!({
5405                        "activityId": &decision_gate_id,
5406                        "resolution": "approved",
5407                        "by": "approver",
5408                        "comment": "lgtm",
5409                    }),
5410                    timestamp: None,
5411                    payload_hash: None,
5412                })
5413                .unwrap();
5414        }
5415
5416        // Process approval → loop and run succeed.
5417        {
5418            let mut hooks = RichFakeHooks;
5419            let mut rt = WorkflowRuntimeContext {
5420                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5421                def,
5422                runs_base_dir: paths.workflow_runs_dir(),
5423            };
5424            let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5425
5426            let events = rt.log.read_all().unwrap();
5427
5428            let loop_finished = events.iter().find(|e| {
5429                e.event_type == "loopFinished"
5430                    && e.payload.get("loopId").and_then(Value::as_str) == Some("review-loop")
5431            });
5432            assert!(loop_finished.is_some(), "expected loopFinished");
5433            let payload = &loop_finished.unwrap().payload;
5434            assert_eq!(
5435                payload.get("resolution").and_then(Value::as_str),
5436                Some("approved")
5437            );
5438
5439            let run_succeeded = events.iter().any(|e| e.event_type == "runSucceeded");
5440            assert!(run_succeeded, "expected run to succeed after loop approval");
5441
5442            assert!(matches!(result.reason, RunLoopStopReason::Terminal));
5443
5444            // Verify loop output exists.
5445            let snap = read_snapshot(&rt).await.unwrap();
5446            let loop_output_key = format!("{}::work::review-loop", run_id);
5447            assert!(
5448                snap.outputs.contains_key(&loop_output_key),
5449                "expected loop output"
5450            );
5451        }
5452
5453        let _ = fs::remove_dir_all(&run_dir);
5454    }
5455}