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::{DecisionNode, HumanGate, LoopNode, LoopOutputProjection, LoopTerminate};
1410    use crate::workflow_snapshot::NodeStatus;
1411    use std::collections::{BTreeMap, HashMap};
1412    use std::sync::Arc;
1413    use tokio::sync::Mutex;
1414
1415    #[derive(Clone)]
1416    struct FakeHooks;
1417
1418    #[async_trait]
1419    impl WorkflowExecutionHooks for FakeHooks {
1420        async fn execute_subagent(
1421            &mut self,
1422            _ctx: WorkflowDispatchRun<'_>,
1423            _node: &SubagentNode,
1424            resolved_prompt: String,
1425        ) -> Result<WorkflowDispatchOutcome> {
1426            Ok(WorkflowDispatchOutcome::Succeeded {
1427                output: Value::String(resolved_prompt),
1428                session: None,
1429            })
1430        }
1431
1432        async fn execute_host_executor(
1433            &mut self,
1434            _ctx: WorkflowDispatchRun<'_>,
1435            _node: &HostExecutorNode,
1436            resolved_input: Value,
1437        ) -> Result<WorkflowDispatchOutcome> {
1438            Ok(WorkflowDispatchOutcome::Succeeded {
1439                output: resolved_input,
1440                session: None,
1441            })
1442        }
1443    }
1444
1445    fn temp_run_dir(label: &str) -> PathBuf {
1446        std::env::temp_dir().join(format!(
1447            "beam-workflow-runtime-{label}-{}-{}",
1448            std::process::id(),
1449            std::time::SystemTime::now()
1450                .duration_since(std::time::UNIX_EPOCH)
1451                .unwrap_or_default()
1452                .as_nanos()
1453        ))
1454    }
1455
1456    fn workflow_def() -> WorkflowDefinition {
1457        WorkflowDefinition {
1458            workflow_id: "flow-a".to_string(),
1459            version: 1,
1460            params: None,
1461            defaults: None,
1462            nodes: BTreeMap::from([(
1463                "a".to_string(),
1464                WorkflowNode::Subagent(SubagentNode {
1465                    base: NodeBase {
1466                        description: None,
1467                        depends: None,
1468                        human_gate: None,
1469                        retry_policy: None,
1470                        timeout_ms: None,
1471                        max_output_bytes: None,
1472                        output_schema: None,
1473                        unsafe_allow_ungated: None,
1474                    },
1475                    bot: "bot-a".to_string(),
1476                    prompt: Value::String("hello ${params.name}".to_string()),
1477                    working_dir: None,
1478                    model_overrides: None,
1479                    tool_policy: None,
1480                }),
1481            )]),
1482        }
1483    }
1484
1485    #[tokio::test]
1486    async fn run_tick_dispatches_simple_workflow() {
1487        let run_dir = temp_run_dir("tick");
1488        let _ = fs::remove_dir_all(&run_dir);
1489        fs::create_dir_all(run_dir.join("blobs")).unwrap();
1490        fs::write(run_dir.join("params.json"), r#"{"name":"beam"}"#).unwrap();
1491
1492        let paths = crate::BeamPaths::from_root(run_dir.clone());
1493        let params = BTreeMap::from([(String::from("name"), String::from("beam"))]);
1494        let run_id = "run-1";
1495        let bootstrap = crate::bootstrap_workflow_run(
1496            &paths,
1497            crate::BootstrapWorkflowRunInput {
1498                run_id,
1499                workflow_json: r#"{"workflowId":"flow-a","version":1,"nodes":{"a":{"type":"subagent","bot":"bot-a","prompt":"hello ${params.name}"}}}"#,
1500                expected_workflow_id: Some("flow-a"),
1501                params: &params,
1502                initiator: "cli",
1503                chat_binding: Some(RunChatBinding {
1504                    chat_id: "chat-1".to_string(),
1505                    lark_app_id: "app-1".to_string(),
1506                }),
1507            },
1508        )
1509        .unwrap();
1510        let mut rt = WorkflowRuntimeContext {
1511            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
1512            def: workflow_def(),
1513            runs_base_dir: paths.workflow_runs_dir(),
1514        };
1515        let mut hooks = FakeHooks;
1516        let tick = run_tick(&mut rt, &mut hooks, 1).await.unwrap();
1517        assert!(tick.actions > 0);
1518        assert_eq!(tick.snapshot.run.workflow_id.as_deref(), Some("flow-a"));
1519        assert!(matches!(
1520            tick.snapshot
1521                .nodes
1522                .iter()
1523                .find(|node| node.node_id == "a")
1524                .map(|node| node.status),
1525            Some(NodeStatus::Succeeded | NodeStatus::Running | NodeStatus::Triggered)
1526        ));
1527        let _ = fs::remove_dir_all(&run_dir);
1528        let _ = bootstrap;
1529    }
1530
1531    #[tokio::test]
1532    async fn run_tick_honors_max_concurrency_cap() {
1533        let run_dir = temp_run_dir("cap");
1534        let _ = fs::remove_dir_all(&run_dir);
1535        fs::create_dir_all(run_dir.join("blobs")).unwrap();
1536        let paths = crate::BeamPaths::from_root(run_dir.clone());
1537        let params = BTreeMap::new();
1538        let run_id = "run-cap";
1539        crate::bootstrap_workflow_run(
1540            &paths,
1541            crate::BootstrapWorkflowRunInput {
1542                run_id,
1543                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"}}}"#,
1544                expected_workflow_id: Some("flow-cap"),
1545                params: &params,
1546                initiator: "cli",
1547                chat_binding: Some(RunChatBinding {
1548                    chat_id: "chat-1".to_string(),
1549                    lark_app_id: "app-1".to_string(),
1550                }),
1551            },
1552        )
1553        .unwrap();
1554        let mut rt = WorkflowRuntimeContext {
1555            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
1556            def: WorkflowDefinition {
1557                workflow_id: "flow-cap".to_string(),
1558                version: 1,
1559                params: None,
1560                defaults: None,
1561                nodes: BTreeMap::from([
1562                    (
1563                        "a".to_string(),
1564                        WorkflowNode::Subagent(SubagentNode {
1565                            base: NodeBase {
1566                                description: None,
1567                                depends: None,
1568                                human_gate: None,
1569                                retry_policy: None,
1570                                timeout_ms: None,
1571                                max_output_bytes: None,
1572                                output_schema: None,
1573                                unsafe_allow_ungated: None,
1574                            },
1575                            bot: "bot-a".to_string(),
1576                            prompt: Value::String("one".to_string()),
1577                            working_dir: None,
1578                            model_overrides: None,
1579                            tool_policy: None,
1580                        }),
1581                    ),
1582                    (
1583                        "b".to_string(),
1584                        WorkflowNode::Subagent(SubagentNode {
1585                            base: NodeBase {
1586                                description: None,
1587                                depends: None,
1588                                human_gate: None,
1589                                retry_policy: None,
1590                                timeout_ms: None,
1591                                max_output_bytes: None,
1592                                output_schema: None,
1593                                unsafe_allow_ungated: None,
1594                            },
1595                            bot: "bot-b".to_string(),
1596                            prompt: Value::String("two".to_string()),
1597                            working_dir: None,
1598                            model_overrides: None,
1599                            tool_policy: None,
1600                        }),
1601                    ),
1602                ]),
1603            },
1604            runs_base_dir: paths.workflow_runs_dir(),
1605        };
1606        let mut hooks = FakeHooks;
1607        let tick = run_tick(&mut rt, &mut hooks, 1).await.unwrap();
1608        assert_eq!(tick.actions, 1);
1609        let snapshot = tick.snapshot;
1610        let attempted: Vec<_> = snapshot
1611            .activities
1612            .iter()
1613            .map(|a| a.activity_id.as_str())
1614            .collect();
1615        assert_eq!(attempted.len(), 1);
1616        let _ = fs::remove_dir_all(&run_dir);
1617    }
1618
1619    #[tokio::test]
1620    async fn run_tick_dispatches_multiple_actions_concurrently() {
1621        let run_dir = temp_run_dir("concurrent");
1622        let _ = fs::remove_dir_all(&run_dir);
1623        fs::create_dir_all(run_dir.join("blobs")).unwrap();
1624        let paths = crate::BeamPaths::from_root(run_dir.clone());
1625        let params = BTreeMap::new();
1626        let run_id = "run-concurrent";
1627        crate::bootstrap_workflow_run(
1628            &paths,
1629            crate::BootstrapWorkflowRunInput {
1630                run_id,
1631                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"}}}"#,
1632                expected_workflow_id: Some("flow-concurrent"),
1633                params: &params,
1634                initiator: "cli",
1635                chat_binding: Some(RunChatBinding {
1636                    chat_id: "chat-1".to_string(),
1637                    lark_app_id: "app-1".to_string(),
1638                }),
1639            },
1640        )
1641        .unwrap();
1642        let mut rt = WorkflowRuntimeContext {
1643            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
1644            def: WorkflowDefinition {
1645                workflow_id: "flow-concurrent".to_string(),
1646                version: 1,
1647                params: None,
1648                defaults: None,
1649                nodes: BTreeMap::from([
1650                    (
1651                        "a".to_string(),
1652                        WorkflowNode::Subagent(SubagentNode {
1653                            base: NodeBase {
1654                                description: None,
1655                                depends: None,
1656                                human_gate: None,
1657                                retry_policy: None,
1658                                timeout_ms: None,
1659                                max_output_bytes: None,
1660                                output_schema: None,
1661                                unsafe_allow_ungated: None,
1662                            },
1663                            bot: "bot-a".to_string(),
1664                            prompt: Value::String("one".to_string()),
1665                            working_dir: None,
1666                            model_overrides: None,
1667                            tool_policy: None,
1668                        }),
1669                    ),
1670                    (
1671                        "b".to_string(),
1672                        WorkflowNode::Subagent(SubagentNode {
1673                            base: NodeBase {
1674                                description: None,
1675                                depends: None,
1676                                human_gate: None,
1677                                retry_policy: None,
1678                                timeout_ms: None,
1679                                max_output_bytes: None,
1680                                output_schema: None,
1681                                unsafe_allow_ungated: None,
1682                            },
1683                            bot: "bot-b".to_string(),
1684                            prompt: Value::String("two".to_string()),
1685                            working_dir: None,
1686                            model_overrides: None,
1687                            tool_policy: None,
1688                        }),
1689                    ),
1690                ]),
1691            },
1692            runs_base_dir: paths.workflow_runs_dir(),
1693        };
1694
1695        #[derive(Clone)]
1696        struct SleepyHooks;
1697
1698        #[async_trait]
1699        impl WorkflowExecutionHooks for SleepyHooks {
1700            async fn execute_subagent(
1701                &mut self,
1702                _ctx: WorkflowDispatchRun<'_>,
1703                _node: &SubagentNode,
1704                resolved_prompt: String,
1705            ) -> Result<WorkflowDispatchOutcome> {
1706                tokio::time::sleep(std::time::Duration::from_millis(120)).await;
1707                Ok(WorkflowDispatchOutcome::Succeeded {
1708                    output: Value::String(resolved_prompt),
1709                    session: None,
1710                })
1711            }
1712
1713            async fn execute_host_executor(
1714                &mut self,
1715                _ctx: WorkflowDispatchRun<'_>,
1716                _node: &HostExecutorNode,
1717                resolved_input: Value,
1718            ) -> Result<WorkflowDispatchOutcome> {
1719                Ok(WorkflowDispatchOutcome::Succeeded {
1720                    output: resolved_input,
1721                    session: None,
1722                })
1723            }
1724        }
1725
1726        let mut hooks = SleepyHooks;
1727        let started = std::time::Instant::now();
1728        let tick = run_tick(&mut rt, &mut hooks, 2).await.unwrap();
1729        let elapsed = started.elapsed();
1730        assert_eq!(tick.actions, 2);
1731        assert!(
1732            elapsed < std::time::Duration::from_millis(220),
1733            "run_tick took {:?}, expected concurrent execution under 220ms",
1734            elapsed
1735        );
1736        let _ = fs::remove_dir_all(&run_dir);
1737    }
1738
1739    #[tokio::test]
1740    async fn run_tick_serializes_actions_for_the_same_bot() {
1741        let run_dir = temp_run_dir("same-bot");
1742        let _ = fs::remove_dir_all(&run_dir);
1743        fs::create_dir_all(run_dir.join("blobs")).unwrap();
1744        let paths = crate::BeamPaths::from_root(run_dir.clone());
1745        let params = BTreeMap::new();
1746        let run_id = "run-same-bot";
1747        crate::bootstrap_workflow_run(
1748            &paths,
1749            crate::BootstrapWorkflowRunInput {
1750                run_id,
1751                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"}}}"#,
1752                expected_workflow_id: Some("flow-same-bot"),
1753                params: &params,
1754                initiator: "cli",
1755                chat_binding: Some(RunChatBinding {
1756                    chat_id: "chat-1".to_string(),
1757                    lark_app_id: "app-1".to_string(),
1758                }),
1759            },
1760        )
1761        .unwrap();
1762        let mut rt = WorkflowRuntimeContext {
1763            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
1764            def: WorkflowDefinition {
1765                workflow_id: "flow-same-bot".to_string(),
1766                version: 1,
1767                params: None,
1768                defaults: None,
1769                nodes: BTreeMap::from([
1770                    (
1771                        "a".to_string(),
1772                        WorkflowNode::Subagent(SubagentNode {
1773                            base: NodeBase {
1774                                description: None,
1775                                depends: None,
1776                                human_gate: None,
1777                                retry_policy: None,
1778                                timeout_ms: None,
1779                                max_output_bytes: None,
1780                                output_schema: None,
1781                                unsafe_allow_ungated: None,
1782                            },
1783                            bot: "bot-shared".to_string(),
1784                            prompt: Value::String("one".to_string()),
1785                            working_dir: None,
1786                            model_overrides: None,
1787                            tool_policy: None,
1788                        }),
1789                    ),
1790                    (
1791                        "b".to_string(),
1792                        WorkflowNode::Subagent(SubagentNode {
1793                            base: NodeBase {
1794                                description: None,
1795                                depends: None,
1796                                human_gate: None,
1797                                retry_policy: None,
1798                                timeout_ms: None,
1799                                max_output_bytes: None,
1800                                output_schema: None,
1801                                unsafe_allow_ungated: None,
1802                            },
1803                            bot: "bot-shared".to_string(),
1804                            prompt: Value::String("two".to_string()),
1805                            working_dir: None,
1806                            model_overrides: None,
1807                            tool_policy: None,
1808                        }),
1809                    ),
1810                    (
1811                        "c".to_string(),
1812                        WorkflowNode::Subagent(SubagentNode {
1813                            base: NodeBase {
1814                                description: None,
1815                                depends: None,
1816                                human_gate: None,
1817                                retry_policy: None,
1818                                timeout_ms: None,
1819                                max_output_bytes: None,
1820                                output_schema: None,
1821                                unsafe_allow_ungated: None,
1822                            },
1823                            bot: "bot-other".to_string(),
1824                            prompt: Value::String("three".to_string()),
1825                            working_dir: None,
1826                            model_overrides: None,
1827                            tool_policy: None,
1828                        }),
1829                    ),
1830                ]),
1831            },
1832            runs_base_dir: paths.workflow_runs_dir(),
1833        };
1834
1835        #[derive(Clone)]
1836        struct SerializingHooks {
1837            started: Arc<Mutex<Vec<String>>>,
1838            active_by_bot: Arc<Mutex<HashMap<String, usize>>>,
1839            max_active_by_bot: Arc<Mutex<HashMap<String, usize>>>,
1840        }
1841
1842        #[async_trait]
1843        impl WorkflowExecutionHooks for SerializingHooks {
1844            async fn execute_subagent(
1845                &mut self,
1846                _ctx: WorkflowDispatchRun<'_>,
1847                node: &SubagentNode,
1848                resolved_prompt: String,
1849            ) -> Result<WorkflowDispatchOutcome> {
1850                let bot = node.bot.clone();
1851                {
1852                    let mut active = self.active_by_bot.lock().await;
1853                    let entry = active.entry(bot.clone()).or_insert(0);
1854                    *entry += 1;
1855                    let mut max_active = self.max_active_by_bot.lock().await;
1856                    let max_entry = max_active.entry(bot.clone()).or_insert(0);
1857                    if *entry > *max_entry {
1858                        *max_entry = *entry;
1859                    }
1860                    assert!(
1861                        *entry <= 1,
1862                        "bot {} ran concurrently with itself: {}",
1863                        bot,
1864                        *entry
1865                    );
1866                }
1867                self.started.lock().await.push(resolved_prompt.clone());
1868                tokio::time::sleep(std::time::Duration::from_millis(120)).await;
1869                {
1870                    let mut active = self.active_by_bot.lock().await;
1871                    if let Some(entry) = active.get_mut(&bot) {
1872                        *entry = entry.saturating_sub(1);
1873                    }
1874                }
1875                Ok(WorkflowDispatchOutcome::Succeeded {
1876                    output: Value::String(resolved_prompt),
1877                    session: None,
1878                })
1879            }
1880
1881            async fn execute_host_executor(
1882                &mut self,
1883                _ctx: WorkflowDispatchRun<'_>,
1884                _node: &HostExecutorNode,
1885                resolved_input: Value,
1886            ) -> Result<WorkflowDispatchOutcome> {
1887                Ok(WorkflowDispatchOutcome::Succeeded {
1888                    output: resolved_input,
1889                    session: None,
1890                })
1891            }
1892        }
1893
1894        let started = Arc::new(Mutex::new(Vec::new()));
1895        let active_by_bot = Arc::new(Mutex::new(HashMap::new()));
1896        let max_active_by_bot = Arc::new(Mutex::new(HashMap::new()));
1897        let mut hooks = SerializingHooks {
1898            started: started.clone(),
1899            active_by_bot: active_by_bot.clone(),
1900            max_active_by_bot: max_active_by_bot.clone(),
1901        };
1902
1903        let started_at = std::time::Instant::now();
1904        let tick = run_tick(&mut rt, &mut hooks, 2).await.unwrap();
1905        let elapsed = started_at.elapsed();
1906
1907        assert_eq!(tick.actions, 2);
1908        let started = started.lock().await.clone();
1909        assert_eq!(started.len(), 2);
1910        assert!(started.contains(&"one".to_string()));
1911        assert!(started.contains(&"three".to_string()));
1912        assert!(!started.contains(&"two".to_string()));
1913        let max_active_by_bot = max_active_by_bot.lock().await;
1914        assert_eq!(max_active_by_bot.get("bot-shared"), Some(&1));
1915        assert_eq!(max_active_by_bot.get("bot-other"), Some(&1));
1916        assert!(
1917            elapsed < std::time::Duration::from_millis(220),
1918            "run_tick took {:?}, expected two distinct bots to run concurrently",
1919            elapsed
1920        );
1921        let _ = fs::remove_dir_all(&run_dir);
1922    }
1923
1924    #[derive(Clone)]
1925    struct CancellingHooks {
1926        run_id: String,
1927        runs_base_dir: PathBuf,
1928        calls: Arc<Mutex<usize>>,
1929        completed: Arc<Mutex<usize>>,
1930    }
1931
1932    #[async_trait]
1933    impl WorkflowExecutionHooks for CancellingHooks {
1934        async fn execute_subagent(
1935            &mut self,
1936            _ctx: WorkflowDispatchRun<'_>,
1937            _node: &SubagentNode,
1938            resolved_prompt: String,
1939        ) -> Result<WorkflowDispatchOutcome> {
1940            let mut calls = self.calls.lock().await;
1941            *calls += 1;
1942            if *calls == 1 {
1943                let run_id = self.run_id.clone();
1944                let runs_base_dir = self.runs_base_dir.clone();
1945                tokio::spawn(async move {
1946                    tokio::time::sleep(std::time::Duration::from_millis(1)).await;
1947                    let mut log = EventLog::new(run_id.clone(), runs_base_dir).unwrap();
1948                    let _ = log.append(EventDraft {
1949                        event_type: "cancelRequested".to_string(),
1950                        actor: WorkflowActor::Human,
1951                        payload: serde_json::json!({
1952                            "target": { "kind": "run", "runId": run_id },
1953                            "reason": "cancel mid tick",
1954                            "by": "tester",
1955                        }),
1956                        timestamp: None,
1957                        payload_hash: None,
1958                    });
1959                });
1960            }
1961            tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1962            *self.completed.lock().await += 1;
1963            Ok(WorkflowDispatchOutcome::Succeeded {
1964                output: Value::String(resolved_prompt),
1965                session: None,
1966            })
1967        }
1968
1969        async fn execute_host_executor(
1970            &mut self,
1971            _ctx: WorkflowDispatchRun<'_>,
1972            _node: &HostExecutorNode,
1973            resolved_input: Value,
1974        ) -> Result<WorkflowDispatchOutcome> {
1975            Ok(WorkflowDispatchOutcome::Succeeded {
1976                output: resolved_input,
1977                session: None,
1978            })
1979        }
1980    }
1981
1982    #[tokio::test]
1983    async fn run_tick_stops_between_actions_when_cancel_arrives() {
1984        let run_dir = temp_run_dir("cancel-mid-tick");
1985        let _ = fs::remove_dir_all(&run_dir);
1986        fs::create_dir_all(run_dir.join("blobs")).unwrap();
1987        let paths = crate::BeamPaths::from_root(run_dir.clone());
1988        let run_id = "run-cancel";
1989        crate::bootstrap_workflow_run(
1990            &paths,
1991            crate::BootstrapWorkflowRunInput {
1992                run_id,
1993                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"}}}"#,
1994                expected_workflow_id: Some("flow-cancel"),
1995                params: &BTreeMap::new(),
1996                initiator: "cli",
1997                chat_binding: Some(RunChatBinding {
1998                    chat_id: "chat-1".to_string(),
1999                    lark_app_id: "app-1".to_string(),
2000                }),
2001            },
2002        )
2003        .unwrap();
2004        let mut rt = WorkflowRuntimeContext {
2005            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2006            def: WorkflowDefinition {
2007                workflow_id: "flow-cancel".to_string(),
2008                version: 1,
2009                params: None,
2010                defaults: None,
2011                nodes: BTreeMap::from([
2012                    (
2013                        "a".to_string(),
2014                        WorkflowNode::Subagent(SubagentNode {
2015                            base: NodeBase {
2016                                description: None,
2017                                depends: None,
2018                                human_gate: None,
2019                                retry_policy: None,
2020                                timeout_ms: None,
2021                                max_output_bytes: None,
2022                                output_schema: None,
2023                                unsafe_allow_ungated: None,
2024                            },
2025                            bot: "bot-a".to_string(),
2026                            prompt: Value::String("one".to_string()),
2027                            working_dir: None,
2028                            model_overrides: None,
2029                            tool_policy: None,
2030                        }),
2031                    ),
2032                    (
2033                        "b".to_string(),
2034                        WorkflowNode::Subagent(SubagentNode {
2035                            base: NodeBase {
2036                                description: None,
2037                                depends: None,
2038                                human_gate: None,
2039                                retry_policy: None,
2040                                timeout_ms: None,
2041                                max_output_bytes: None,
2042                                output_schema: None,
2043                                unsafe_allow_ungated: None,
2044                            },
2045                            bot: "bot-b".to_string(),
2046                            prompt: Value::String("two".to_string()),
2047                            working_dir: None,
2048                            model_overrides: None,
2049                            tool_policy: None,
2050                        }),
2051                    ),
2052                ]),
2053            },
2054            runs_base_dir: paths.workflow_runs_dir(),
2055        };
2056        let mut hooks = CancellingHooks {
2057            run_id: run_id.to_string(),
2058            runs_base_dir: paths.workflow_runs_dir(),
2059            calls: Arc::new(Mutex::new(0)),
2060            completed: Arc::new(Mutex::new(0)),
2061        };
2062        let started = std::time::Instant::now();
2063        let tick = run_tick(&mut rt, &mut hooks, 2).await.unwrap();
2064        let elapsed = started.elapsed();
2065        assert!(tick.actions < 2);
2066        assert!(tick.snapshot.run.cancelled_run_intent.is_some());
2067        assert!(tick.snapshot.activities.len() >= 1);
2068        assert!(
2069            elapsed < std::time::Duration::from_millis(250),
2070            "run_tick took {:?}, expected cancel to interrupt long-running actions",
2071            elapsed
2072        );
2073        assert_eq!(*hooks.completed.lock().await, 0);
2074        let _ = fs::remove_dir_all(&run_dir);
2075    }
2076
2077    #[tokio::test]
2078    async fn run_tick_skips_dispatch_when_run_cancel_is_pending() {
2079        let run_dir = temp_run_dir("reconcile-cancel");
2080        let _ = fs::remove_dir_all(&run_dir);
2081        fs::create_dir_all(run_dir.join("blobs")).unwrap();
2082        let paths = crate::BeamPaths::from_root(run_dir.clone());
2083        let run_id = "run-reconcile-cancel";
2084        crate::bootstrap_workflow_run(
2085            &paths,
2086            crate::BootstrapWorkflowRunInput {
2087                run_id,
2088                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"}}}"#,
2089                expected_workflow_id: Some("flow-reconcile"),
2090                params: &BTreeMap::new(),
2091                initiator: "cli",
2092                chat_binding: Some(RunChatBinding {
2093                    chat_id: "chat-1".to_string(),
2094                    lark_app_id: "app-1".to_string(),
2095                }),
2096            },
2097        )
2098        .unwrap();
2099        let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
2100        let _ = crate::request_cancel(
2101            &mut log,
2102            crate::RequestCancelInput {
2103                target: serde_json::json!({
2104                    "kind": "run",
2105                    "runId": run_id,
2106                }),
2107                reason: "cancel before dispatch".to_string(),
2108                by: "tester".to_string(),
2109            },
2110            WorkflowActor::Human,
2111        )
2112        .await
2113        .unwrap();
2114        let mut rt = WorkflowRuntimeContext {
2115            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2116            def: WorkflowDefinition {
2117                workflow_id: "flow-reconcile".to_string(),
2118                version: 1,
2119                params: None,
2120                defaults: None,
2121                nodes: BTreeMap::from([
2122                    (
2123                        "a".to_string(),
2124                        WorkflowNode::Subagent(SubagentNode {
2125                            base: NodeBase {
2126                                description: None,
2127                                depends: None,
2128                                human_gate: None,
2129                                retry_policy: None,
2130                                timeout_ms: None,
2131                                max_output_bytes: None,
2132                                output_schema: None,
2133                                unsafe_allow_ungated: None,
2134                            },
2135                            bot: "bot-a".to_string(),
2136                            prompt: Value::String("one".to_string()),
2137                            working_dir: None,
2138                            model_overrides: None,
2139                            tool_policy: None,
2140                        }),
2141                    ),
2142                    (
2143                        "b".to_string(),
2144                        WorkflowNode::Subagent(SubagentNode {
2145                            base: NodeBase {
2146                                description: None,
2147                                depends: None,
2148                                human_gate: None,
2149                                retry_policy: None,
2150                                timeout_ms: None,
2151                                max_output_bytes: None,
2152                                output_schema: None,
2153                                unsafe_allow_ungated: None,
2154                            },
2155                            bot: "bot-b".to_string(),
2156                            prompt: Value::String("two".to_string()),
2157                            working_dir: None,
2158                            model_overrides: None,
2159                            tool_policy: None,
2160                        }),
2161                    ),
2162                ]),
2163            },
2164            runs_base_dir: paths.workflow_runs_dir(),
2165        };
2166        let mut hooks = FakeHooks;
2167        let tick = run_tick(&mut rt, &mut hooks, 2).await.unwrap();
2168        assert_eq!(tick.actions, 0);
2169        assert!(tick.snapshot.run.cancelled_run_intent.is_some());
2170        let _ = fs::remove_dir_all(&run_dir);
2171    }
2172
2173    #[test]
2174    fn orchestrator_action_is_dispatch_classifies_correctly() {
2175        use crate::workflow_definition::{NodeBase, SubagentNode};
2176        use crate::workflow_orchestrator::OrchestratorAction;
2177        let dispatch = OrchestratorAction::DispatchWork {
2178            node_id: "n1".to_string(),
2179            activity_id: "a1".to_string(),
2180            node: crate::WorkflowNode::Subagent(SubagentNode {
2181                base: NodeBase {
2182                    description: None,
2183                    depends: None,
2184                    human_gate: None,
2185                    retry_policy: None,
2186                    timeout_ms: None,
2187                    max_output_bytes: None,
2188                    output_schema: None,
2189                    unsafe_allow_ungated: None,
2190                },
2191                bot: "b1".to_string(),
2192                prompt: serde_json::json!("p"),
2193                working_dir: None,
2194                model_overrides: None,
2195                tool_policy: None,
2196            }),
2197        };
2198        assert!(dispatch.is_dispatch());
2199
2200        let settle = OrchestratorAction::CompleteNodeSucceeded {
2201            node_id: "n1".to_string(),
2202            last_activity_id: "a1".to_string(),
2203            output_ref: None,
2204        };
2205        assert!(!settle.is_dispatch());
2206    }
2207
2208    #[tokio::test]
2209    async fn run_loop_stops_when_progress_is_exhausted() {
2210        let run_dir = temp_run_dir("loop");
2211        let _ = fs::remove_dir_all(&run_dir);
2212        fs::create_dir_all(run_dir.join("blobs")).unwrap();
2213        let paths = crate::BeamPaths::from_root(run_dir.clone());
2214        let params = BTreeMap::from([(String::from("name"), String::from("beam"))]);
2215        let run_id = "run-1";
2216        crate::bootstrap_workflow_run(
2217            &paths,
2218            crate::BootstrapWorkflowRunInput {
2219                run_id,
2220                workflow_json: r#"{"workflowId":"flow-a","version":1,"nodes":{"a":{"type":"subagent","bot":"bot-a","prompt":"hello ${params.name}"}}}"#,
2221                expected_workflow_id: Some("flow-a"),
2222                params: &params,
2223                initiator: "cli",
2224                chat_binding: Some(RunChatBinding {
2225                    chat_id: "chat-1".to_string(),
2226                    lark_app_id: "app-1".to_string(),
2227                }),
2228            },
2229        )
2230        .unwrap();
2231        let mut rt = WorkflowRuntimeContext {
2232            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2233            def: workflow_def(),
2234            runs_base_dir: paths.workflow_runs_dir(),
2235        };
2236        let mut hooks = FakeHooks;
2237        let result = run_loop(&mut rt, &mut hooks, 3, 1).await.unwrap();
2238        assert!(matches!(
2239            result.reason,
2240            RunLoopStopReason::Terminal | RunLoopStopReason::NoProgress
2241        ));
2242        assert!(result.ticks > 0);
2243        let _ = fs::remove_dir_all(&run_dir);
2244    }
2245
2246    // --- Recovery phase tests (Task 4.1) ---
2247
2248    /// A hook that counts how many times `recover_dangling_effects` is called.
2249    #[derive(Clone)]
2250    struct RecoveryCountingHooks {
2251        recovery_calls: Arc<Mutex<usize>>,
2252    }
2253
2254    #[async_trait]
2255    impl WorkflowExecutionHooks for RecoveryCountingHooks {
2256        async fn execute_subagent(
2257            &mut self,
2258            _ctx: WorkflowDispatchRun<'_>,
2259            _node: &SubagentNode,
2260            resolved_prompt: String,
2261        ) -> Result<WorkflowDispatchOutcome> {
2262            Ok(WorkflowDispatchOutcome::Succeeded {
2263                output: Value::String(resolved_prompt),
2264                session: None,
2265            })
2266        }
2267
2268        async fn execute_host_executor(
2269            &mut self,
2270            _ctx: WorkflowDispatchRun<'_>,
2271            _node: &HostExecutorNode,
2272            resolved_input: Value,
2273        ) -> Result<WorkflowDispatchOutcome> {
2274            Ok(WorkflowDispatchOutcome::Succeeded {
2275                output: resolved_input,
2276                session: None,
2277            })
2278        }
2279
2280        async fn recover_dangling_effects(
2281            &mut self,
2282            _log: &mut EventLog,
2283            _snapshot: &RunSnapshotDTO,
2284        ) -> Result<RecoveryResult> {
2285            *self.recovery_calls.lock().await += 1;
2286            Ok(RecoveryResult {
2287                had_progress: false,
2288                has_remaining_dangling: true,
2289            })
2290        }
2291    }
2292
2293    #[tokio::test]
2294    async fn run_loop_calls_recovery_when_dangling_effects_present() {
2295        let run_dir = temp_run_dir("recovery-call");
2296        let _ = fs::remove_dir_all(&run_dir);
2297        fs::create_dir_all(run_dir.join("blobs")).unwrap();
2298        let paths = crate::BeamPaths::from_root(run_dir.clone());
2299        let run_id = "run-recovery-call";
2300        let _ = crate::bootstrap_workflow_run(
2301            &paths,
2302            crate::BootstrapWorkflowRunInput {
2303                run_id,
2304                workflow_json: r#"{"workflowId":"flow-recovery","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-send","input":{"chatId":"chat-1","content":"hello"},"unsafeAllowUngated":true}}}"#,
2305                expected_workflow_id: Some("flow-recovery"),
2306                params: &BTreeMap::new(),
2307                initiator: "cli",
2308                chat_binding: None,
2309            },
2310        )
2311        .unwrap();
2312
2313        // Activity id that the orchestrator expects for node "a" work.
2314        let work_activity_id = format!("{}::work::a", run_id);
2315        let work_attempt_id = format!("{}::work::a::att-1", run_id);
2316
2317        // Write events that create a dangling effectAttempted activity matching
2318        // the orchestrator's work activity id.
2319        {
2320            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
2321            let _ = log
2322                .append(EventDraft {
2323                    event_type: "attemptCreated".to_string(),
2324                    actor: WorkflowActor::Scheduler,
2325                    payload: serde_json::json!({
2326                        "nodeId": "a",
2327                        "activityId": &work_activity_id,
2328                        "attemptId": &work_attempt_id,
2329                        "attemptNumber": 1,
2330                        "inputRef": {
2331                            "outputHash": "sha256:aa",
2332                            "outputPath": "/tmp/aa",
2333                            "outputBytes": 2,
2334                            "outputSchemaVersion": 1,
2335                            "contentType": "application/json"
2336                        }
2337                    }),
2338                    timestamp: None,
2339                    payload_hash: None,
2340                })
2341                .unwrap();
2342            let _ = log
2343                .append(EventDraft {
2344                    event_type: "effectAttempted".to_string(),
2345                    actor: WorkflowActor::HostExecutor,
2346                    payload: serde_json::json!({
2347                        "activityId": &work_activity_id,
2348                        "attemptId": &work_attempt_id,
2349                        "idempotencyKey": "wf_key_recovery",
2350                        "inputHash": "sha256:bb",
2351                        "idempotencyTtlMs": 60000u64,
2352                        "provider": "feishu-im",
2353                    }),
2354                    timestamp: None,
2355                    payload_hash: None,
2356                })
2357                .unwrap();
2358        }
2359
2360        let mut rt = WorkflowRuntimeContext {
2361            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2362            def: WorkflowDefinition {
2363                workflow_id: "flow-recovery".to_string(),
2364                version: 1,
2365                params: None,
2366                defaults: None,
2367                nodes: BTreeMap::from([(
2368                    "a".to_string(),
2369                    WorkflowNode::HostExecutor(HostExecutorNode {
2370                        base: NodeBase {
2371                            description: None,
2372                            depends: None,
2373                            human_gate: None,
2374                            retry_policy: None,
2375                            timeout_ms: None,
2376                            max_output_bytes: None,
2377                            output_schema: None,
2378                            unsafe_allow_ungated: Some(true),
2379                        },
2380                        executor: "feishu-send".to_string(),
2381                        input: serde_json::json!({"chatId":"chat-1","content":"hello"}),
2382                    }),
2383                )]),
2384            },
2385            runs_base_dir: paths.workflow_runs_dir(),
2386        };
2387
2388        let recovery_calls = Arc::new(Mutex::new(0usize));
2389        let mut hooks = RecoveryCountingHooks {
2390            recovery_calls: recovery_calls.clone(),
2391        };
2392        let result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
2393
2394        let calls = *recovery_calls.lock().await;
2395        assert!(
2396            calls > 0,
2397            "expected recover_dangling_effects to be called at least once, got {calls}"
2398        );
2399        // Since recovery returned had_progress=false, the loop should fall
2400        // through to run_tick and eventually stop (not loop forever on recovery).
2401        assert!(
2402            matches!(
2403                result.reason,
2404                RunLoopStopReason::NoProgress | RunLoopStopReason::AwaitingWait
2405            ),
2406            "expected NoProgress or AwaitingWait, got {:?}",
2407            result.reason
2408        );
2409        let _ = fs::remove_dir_all(&run_dir);
2410    }
2411
2412    /// A hook that simulates successful recovery: writes activitySucceeded for
2413    /// every dangling effectAttempted activity.
2414    #[derive(Clone)]
2415    struct RecoveringHooks;
2416
2417    #[async_trait]
2418    impl WorkflowExecutionHooks for RecoveringHooks {
2419        async fn execute_subagent(
2420            &mut self,
2421            _ctx: WorkflowDispatchRun<'_>,
2422            _node: &SubagentNode,
2423            resolved_prompt: String,
2424        ) -> Result<WorkflowDispatchOutcome> {
2425            Ok(WorkflowDispatchOutcome::Succeeded {
2426                output: Value::String(resolved_prompt),
2427                session: None,
2428            })
2429        }
2430
2431        async fn execute_host_executor(
2432            &mut self,
2433            _ctx: WorkflowDispatchRun<'_>,
2434            _node: &HostExecutorNode,
2435            resolved_input: Value,
2436        ) -> Result<WorkflowDispatchOutcome> {
2437            Ok(WorkflowDispatchOutcome::Succeeded {
2438                output: resolved_input,
2439                session: None,
2440            })
2441        }
2442
2443        async fn recover_dangling_effects(
2444            &mut self,
2445            log: &mut EventLog,
2446            snapshot: &RunSnapshotDTO,
2447        ) -> Result<RecoveryResult> {
2448            let mut had_progress = false;
2449            for activity_id in &snapshot.dangling.effect_attempted {
2450                if let Some(activity) = snapshot
2451                    .activities
2452                    .iter()
2453                    .find(|a| &a.activity_id == activity_id)
2454                {
2455                    if let Some(latest) = activity.attempts.last() {
2456                        let output_ref = WorkflowOutputRef {
2457                            output_hash: "sha256:recovered".to_string(),
2458                            output_path: "/tmp/recovered".to_string(),
2459                            output_bytes: 3,
2460                            output_schema_version: 1,
2461                            content_type: Some("application/json".to_string()),
2462                        };
2463                        let _ = log.append(EventDraft {
2464                            event_type: "activitySucceeded".to_string(),
2465                            actor: WorkflowActor::System,
2466                            payload: serde_json::json!({
2467                                "activityId": activity_id,
2468                                "attemptId": &latest.attempt_id,
2469                                "outputRef": output_ref,
2470                            }),
2471                            timestamp: None,
2472                            payload_hash: None,
2473                        })?;
2474                        had_progress = true;
2475                    }
2476                }
2477            }
2478            Ok(RecoveryResult {
2479                had_progress,
2480                has_remaining_dangling: false,
2481            })
2482        }
2483    }
2484
2485    #[tokio::test]
2486    async fn run_loop_replays_after_recovery_writes_events() {
2487        let run_dir = temp_run_dir("recovery-replay");
2488        let _ = fs::remove_dir_all(&run_dir);
2489        fs::create_dir_all(run_dir.join("blobs")).unwrap();
2490        let paths = crate::BeamPaths::from_root(run_dir.clone());
2491        let run_id = "run-recovery-replay";
2492        let _ = crate::bootstrap_workflow_run(
2493            &paths,
2494            crate::BootstrapWorkflowRunInput {
2495                run_id,
2496                workflow_json: r#"{"workflowId":"flow-replay","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-send","input":{"chatId":"chat-1","content":"hello"},"unsafeAllowUngated":true}}}"#,
2497                expected_workflow_id: Some("flow-replay"),
2498                params: &BTreeMap::new(),
2499                initiator: "cli",
2500                chat_binding: None,
2501            },
2502        )
2503        .unwrap();
2504
2505        // Activity id that the orchestrator expects for node "a" work.
2506        let work_activity_id = format!("{}::work::a", run_id);
2507        let work_attempt_id = format!("{}::work::a::att-1", run_id);
2508
2509        // Write events that create a dangling effectAttempted activity
2510        {
2511            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
2512            let _ = log
2513                .append(EventDraft {
2514                    event_type: "attemptCreated".to_string(),
2515                    actor: WorkflowActor::Scheduler,
2516                    payload: serde_json::json!({
2517                        "nodeId": "a",
2518                        "activityId": &work_activity_id,
2519                        "attemptId": &work_attempt_id,
2520                        "attemptNumber": 1,
2521                        "inputRef": {
2522                            "outputHash": "sha256:aa",
2523                            "outputPath": "/tmp/aa",
2524                            "outputBytes": 2,
2525                            "outputSchemaVersion": 1,
2526                            "contentType": "application/json"
2527                        }
2528                    }),
2529                    timestamp: None,
2530                    payload_hash: None,
2531                })
2532                .unwrap();
2533            let _ = log
2534                .append(EventDraft {
2535                    event_type: "effectAttempted".to_string(),
2536                    actor: WorkflowActor::HostExecutor,
2537                    payload: serde_json::json!({
2538                        "activityId": &work_activity_id,
2539                        "attemptId": &work_attempt_id,
2540                        "idempotencyKey": "wf_key_replay",
2541                        "inputHash": "sha256:bb",
2542                        "idempotencyTtlMs": 60000u64,
2543                        "provider": "feishu-im",
2544                    }),
2545                    timestamp: None,
2546                    payload_hash: None,
2547                })
2548                .unwrap();
2549        }
2550
2551        let mut rt = WorkflowRuntimeContext {
2552            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2553            def: WorkflowDefinition {
2554                workflow_id: "flow-replay".to_string(),
2555                version: 1,
2556                params: None,
2557                defaults: None,
2558                nodes: BTreeMap::from([(
2559                    "a".to_string(),
2560                    WorkflowNode::HostExecutor(HostExecutorNode {
2561                        base: NodeBase {
2562                            description: None,
2563                            depends: None,
2564                            human_gate: None,
2565                            retry_policy: None,
2566                            timeout_ms: None,
2567                            max_output_bytes: None,
2568                            output_schema: None,
2569                            unsafe_allow_ungated: Some(true),
2570                        },
2571                        executor: "feishu-send".to_string(),
2572                        input: serde_json::json!({"chatId":"chat-1","content":"hello"}),
2573                    }),
2574                )]),
2575            },
2576            runs_base_dir: paths.workflow_runs_dir(),
2577        };
2578
2579        let mut hooks = RecoveringHooks;
2580        let result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
2581
2582        // After recovery writes activitySucceeded, the orchestrator should
2583        // be able to complete the node and the run.  Verify that the run
2584        // reached a terminal state or made measurable progress.
2585        let final_snapshot = read_snapshot(&rt).await.unwrap();
2586        assert!(
2587            matches!(
2588                final_snapshot.run.status,
2589                RunStatus::Succeeded | RunStatus::Failed | RunStatus::Cancelled
2590            ) || result.ticks > 0,
2591            "expected recovery to allow progress; reason={:?} ticks={} status={:?}",
2592            result.reason,
2593            result.ticks,
2594            final_snapshot.run.status
2595        );
2596        // Verify the recovery event was written
2597        let events = rt.log.read_all().unwrap();
2598        let has_recovered = events
2599            .iter()
2600            .any(|e| e.event_type == "activitySucceeded" && e.actor == WorkflowActor::System);
2601        assert!(
2602            has_recovered,
2603            "expected a system activitySucceeded from recovery"
2604        );
2605        let _ = fs::remove_dir_all(&run_dir);
2606    }
2607
2608    #[tokio::test]
2609    async fn run_loop_no_infinite_loop_when_recovery_cannot_progress() {
2610        let run_dir = temp_run_dir("recovery-stuck");
2611        let _ = fs::remove_dir_all(&run_dir);
2612        fs::create_dir_all(run_dir.join("blobs")).unwrap();
2613        let paths = crate::BeamPaths::from_root(run_dir.clone());
2614        let run_id = "run-recovery-stuck";
2615        let _ = crate::bootstrap_workflow_run(
2616            &paths,
2617            crate::BootstrapWorkflowRunInput {
2618                run_id,
2619                workflow_json: r#"{"workflowId":"flow-stuck","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-send","input":{"chatId":"chat-1","content":"hello"},"unsafeAllowUngated":true}}}"#,
2620                expected_workflow_id: Some("flow-stuck"),
2621                params: &BTreeMap::new(),
2622                initiator: "cli",
2623                chat_binding: None,
2624            },
2625        )
2626        .unwrap();
2627
2628        // Activity id that the orchestrator expects for node "a" work.
2629        let work_activity_id = format!("{}::work::a", run_id);
2630        let work_attempt_id = format!("{}::work::a::att-1", run_id);
2631
2632        // Write events that create a dangling effectAttempted activity
2633        {
2634            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
2635            let _ = log
2636                .append(EventDraft {
2637                    event_type: "attemptCreated".to_string(),
2638                    actor: WorkflowActor::Scheduler,
2639                    payload: serde_json::json!({
2640                        "nodeId": "a",
2641                        "activityId": &work_activity_id,
2642                        "attemptId": &work_attempt_id,
2643                        "attemptNumber": 1,
2644                        "inputRef": {
2645                            "outputHash": "sha256:aa",
2646                            "outputPath": "/tmp/aa",
2647                            "outputBytes": 2,
2648                            "outputSchemaVersion": 1,
2649                            "contentType": "application/json"
2650                        }
2651                    }),
2652                    timestamp: None,
2653                    payload_hash: None,
2654                })
2655                .unwrap();
2656            let _ = log
2657                .append(EventDraft {
2658                    event_type: "effectAttempted".to_string(),
2659                    actor: WorkflowActor::HostExecutor,
2660                    payload: serde_json::json!({
2661                        "activityId": &work_activity_id,
2662                        "attemptId": &work_attempt_id,
2663                        "idempotencyKey": "wf_key_stuck",
2664                        "inputHash": "sha256:bb",
2665                        "idempotencyTtlMs": 60000u64,
2666                        "provider": "unknown-provider",
2667                    }),
2668                    timestamp: None,
2669                    payload_hash: None,
2670                })
2671                .unwrap();
2672        }
2673
2674        let mut rt = WorkflowRuntimeContext {
2675            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2676            def: WorkflowDefinition {
2677                workflow_id: "flow-stuck".to_string(),
2678                version: 1,
2679                params: None,
2680                defaults: None,
2681                nodes: BTreeMap::from([(
2682                    "a".to_string(),
2683                    WorkflowNode::HostExecutor(HostExecutorNode {
2684                        base: NodeBase {
2685                            description: None,
2686                            depends: None,
2687                            human_gate: None,
2688                            retry_policy: None,
2689                            timeout_ms: None,
2690                            max_output_bytes: None,
2691                            output_schema: None,
2692                            unsafe_allow_ungated: Some(true),
2693                        },
2694                        executor: "feishu-send".to_string(),
2695                        input: serde_json::json!({"chatId":"chat-1","content":"hello"}),
2696                    }),
2697                )]),
2698            },
2699            runs_base_dir: paths.workflow_runs_dir(),
2700        };
2701
2702        // Use FakeHooks which returns had_progress=false for recovery.
2703        let mut hooks = FakeHooks;
2704        let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
2705
2706        // The loop must NOT return MaxTicks (that would mean it consumed all
2707        // ticks in unsuccessful recovery attempts).  With no real progress,
2708        // it should return NoProgress or AwaitingWait.
2709        assert!(
2710            !matches!(result.reason, RunLoopStopReason::MaxTicks),
2711            "expected run_loop to stop without exhausting max_ticks on unrecoverable dangling effects"
2712        );
2713        assert!(
2714            matches!(
2715                result.reason,
2716                RunLoopStopReason::NoProgress | RunLoopStopReason::AwaitingWait
2717            ),
2718            "expected NoProgress or AwaitingWait, got {:?}",
2719            result.reason
2720        );
2721        let _ = fs::remove_dir_all(&run_dir);
2722    }
2723
2724    #[tokio::test]
2725    async fn default_recovery_result_has_correct_semantics() {
2726        // Default implementation: had_progress=false always.
2727        // has_remaining_dangling should match whether the snapshot actually
2728        // contains dangling effect_attempted activities.
2729
2730        // Snapshot with no dangling effects → has_remaining_dangling=false
2731        let empty_snapshot = RunSnapshotDTO {
2732            run_id: "r".to_string(),
2733            run: RunState {
2734                run_id: "r".to_string(),
2735                status: RunStatus::Running,
2736                workflow_id: None,
2737                revision_id: None,
2738                initiator: None,
2739                input: None,
2740                output: None,
2741                failed_node_id: None,
2742                root_cause_event_id: None,
2743                cancel_origin_event_id: None,
2744                bot_snapshots: None,
2745                cancelled_run_intent: None,
2746                cancelled_node_intents: BTreeMap::new(),
2747            },
2748            last_seq: 0,
2749            nodes: vec![],
2750            activities: vec![],
2751            loops: None,
2752            dangling: DanglingSnapshot {
2753                activities: vec![],
2754                effect_attempted: vec![],
2755                waits: vec![],
2756                wait_resolutions: vec![],
2757                cancels: vec![],
2758            },
2759            outputs: BTreeMap::new(),
2760            attempt_io: BTreeMap::new(),
2761            chat_binding: None,
2762            updated_at: 0,
2763        };
2764
2765        // Need a real EventLog for the &mut reference, but FakeHooks doesn't use it.
2766        let run_dir = temp_run_dir("recovery-semantics");
2767        let _ = fs::remove_dir_all(&run_dir);
2768        fs::create_dir_all(&run_dir).unwrap();
2769        let paths = crate::BeamPaths::from_root(run_dir.clone());
2770        let mut log = EventLog::new("r", paths.workflow_runs_dir()).unwrap();
2771
2772        // Use the trait method via a concrete type to test default impl.
2773        // We call it through a concrete struct that doesn't override the default.
2774        #[derive(Clone)]
2775        struct DefaultingHooks;
2776        #[async_trait]
2777        impl WorkflowExecutionHooks for DefaultingHooks {
2778            async fn execute_subagent(
2779                &mut self,
2780                _ctx: WorkflowDispatchRun<'_>,
2781                _node: &SubagentNode,
2782                _resolved_prompt: String,
2783            ) -> Result<WorkflowDispatchOutcome> {
2784                unreachable!()
2785            }
2786            async fn execute_host_executor(
2787                &mut self,
2788                _ctx: WorkflowDispatchRun<'_>,
2789                _node: &HostExecutorNode,
2790                _resolved_input: Value,
2791            ) -> Result<WorkflowDispatchOutcome> {
2792                unreachable!()
2793            }
2794            // Uses default recover_dangling_effects
2795        }
2796
2797        let mut hooks = DefaultingHooks;
2798
2799        // Empty: has_remaining_dangling should be false
2800        let result = hooks
2801            .recover_dangling_effects(&mut log, &empty_snapshot)
2802            .await
2803            .unwrap();
2804        assert!(!result.had_progress, "default: had_progress must be false");
2805        assert!(
2806            !result.has_remaining_dangling,
2807            "empty dangling → has_remaining_dangling must be false"
2808        );
2809
2810        // With dangling effects: has_remaining_dangling should be true
2811        let dangling_snapshot = RunSnapshotDTO {
2812            dangling: DanglingSnapshot {
2813                effect_attempted: vec!["act-1".to_string()],
2814                ..empty_snapshot.dangling.clone()
2815            },
2816            ..empty_snapshot.clone()
2817        };
2818        let result2 = hooks
2819            .recover_dangling_effects(&mut log, &dangling_snapshot)
2820            .await
2821            .unwrap();
2822        assert!(!result2.had_progress, "default: had_progress must be false");
2823        assert!(
2824            result2.has_remaining_dangling,
2825            "dangling present → has_remaining_dangling must be true"
2826        );
2827
2828        let _ = fs::remove_dir_all(&run_dir);
2829    }
2830
2831    // --- Wait resolution projection tests (Task 4.2) ---
2832
2833    /// Verify that an open wait (waitCreated without resolution) causes
2834    /// run_loop to return AwaitingWait rather than NoProgress.
2835    #[tokio::test]
2836    async fn open_wait_makes_run_loop_return_awaiting_wait() {
2837        let run_dir = temp_run_dir("open-wait");
2838        let _ = fs::remove_dir_all(&run_dir);
2839        fs::create_dir_all(run_dir.join("blobs")).unwrap();
2840        let paths = crate::BeamPaths::from_root(run_dir.clone());
2841        let run_id = "run-open-wait";
2842        crate::bootstrap_workflow_run(
2843            &paths,
2844            crate::BootstrapWorkflowRunInput {
2845                run_id,
2846                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"]}}}"#,
2847                expected_workflow_id: Some("flow-open-wait"),
2848                params: &BTreeMap::new(),
2849                initiator: "cli",
2850                chat_binding: Some(RunChatBinding {
2851                    chat_id: "chat-1".to_string(),
2852                    lark_app_id: "app-1".to_string(),
2853                }),
2854            },
2855        )
2856        .unwrap();
2857
2858        // Write a waitCreated event directly — this simulates a workflow that
2859        // dispatched a gate and created a wait but no resolution has arrived yet.
2860        let gate_activity_id = format!("{}::gate::gate", run_id);
2861        let gate_attempt_id = format!("{}::gate::gate::att-1", run_id);
2862        {
2863            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
2864            let _ = log
2865                .append(EventDraft {
2866                    event_type: "attemptCreated".to_string(),
2867                    actor: WorkflowActor::Scheduler,
2868                    payload: serde_json::json!({
2869                        "nodeId": "gate",
2870                        "activityId": &gate_activity_id,
2871                        "attemptId": &gate_attempt_id,
2872                        "attemptNumber": 1,
2873                        "inputRef": {
2874                            "outputHash": "sha256:aa",
2875                            "outputPath": "/tmp/aa",
2876                            "outputBytes": 2,
2877                            "outputSchemaVersion": 1,
2878                            "contentType": "application/json"
2879                        }
2880                    }),
2881                    timestamp: None,
2882                    payload_hash: None,
2883                })
2884                .unwrap();
2885            let _ = log
2886                .append(EventDraft {
2887                    event_type: "waitCreated".to_string(),
2888                    actor: WorkflowActor::Scheduler,
2889                    payload: serde_json::json!({
2890                        "activityId": &gate_activity_id,
2891                        "attemptId": &gate_attempt_id,
2892                        "nodeId": "gate",
2893                        "waitKind": "human-gate",
2894                    }),
2895                    timestamp: None,
2896                    payload_hash: None,
2897                })
2898                .unwrap();
2899        }
2900
2901        let mut rt = WorkflowRuntimeContext {
2902            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2903            def: WorkflowDefinition {
2904                workflow_id: "flow-open-wait".to_string(),
2905                version: 1,
2906                params: None,
2907                defaults: None,
2908                nodes: BTreeMap::from([
2909                    (
2910                        "gate".to_string(),
2911                        WorkflowNode::HostExecutor(HostExecutorNode {
2912                            base: NodeBase {
2913                                description: None,
2914                                depends: None,
2915                                human_gate: Some(crate::workflow_definition::HumanGate {
2916                                    stage: "gate".to_string(),
2917                                    prompt: Value::String("approve?".to_string()),
2918                                    approvers: Some(vec!["admin".to_string()]),
2919                                    deadline_ms: None,
2920                                    on_timeout: None,
2921                                }),
2922                                retry_policy: None,
2923                                timeout_ms: None,
2924                                max_output_bytes: None,
2925                                output_schema: None,
2926                                unsafe_allow_ungated: Some(true),
2927                            },
2928                            executor: "beam-schedule".to_string(),
2929                            input: serde_json::json!({"prompt":"approve"}),
2930                        }),
2931                    ),
2932                    (
2933                        "sink".to_string(),
2934                        WorkflowNode::Subagent(SubagentNode {
2935                            base: NodeBase {
2936                                description: None,
2937                                depends: Some(vec!["gate".to_string()]),
2938                                human_gate: None,
2939                                retry_policy: None,
2940                                timeout_ms: None,
2941                                max_output_bytes: None,
2942                                output_schema: None,
2943                                unsafe_allow_ungated: None,
2944                            },
2945                            bot: "bot-a".to_string(),
2946                            prompt: Value::String("done".to_string()),
2947                            working_dir: None,
2948                            model_overrides: None,
2949                            tool_policy: None,
2950                        }),
2951                    ),
2952                ]),
2953            },
2954            runs_base_dir: paths.workflow_runs_dir(),
2955        };
2956
2957        // Verify snapshot shows open wait (in waits, not in wait_resolutions)
2958        let snap = read_snapshot(&rt).await.unwrap();
2959        assert!(
2960            !snap.dangling.waits.is_empty(),
2961            "expected open wait in dangling.waits, got {:?}",
2962            snap.dangling.waits
2963        );
2964        assert!(
2965            snap.dangling.wait_resolutions.is_empty(),
2966            "expected no wait_resolutions for open wait"
2967        );
2968
2969        let mut hooks = FakeHooks;
2970        let result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
2971        assert_eq!(
2972            result.reason,
2973            RunLoopStopReason::AwaitingWait,
2974            "expected AwaitingWait for open wait, got {:?}",
2975            result.reason
2976        );
2977
2978        let _ = fs::remove_dir_all(&run_dir);
2979    }
2980
2981    /// Verify that a resolved wait without a terminal event (dangling
2982    /// wait_resolution) gets materialized during run_loop's recovery phase,
2983    /// allowing the workflow to continue.
2984    #[tokio::test]
2985    async fn run_loop_materializes_terminal_for_resolved_wait() {
2986        let run_dir = temp_run_dir("resolved-wait");
2987        let _ = fs::remove_dir_all(&run_dir);
2988        fs::create_dir_all(run_dir.join("blobs")).unwrap();
2989        let paths = crate::BeamPaths::from_root(run_dir.clone());
2990        let run_id = "run-resolved-wait";
2991        crate::bootstrap_workflow_run(
2992            &paths,
2993            crate::BootstrapWorkflowRunInput {
2994                run_id,
2995                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"]}}}"#,
2996                expected_workflow_id: Some("flow-resolved-wait"),
2997                params: &BTreeMap::new(),
2998                initiator: "cli",
2999                chat_binding: Some(RunChatBinding {
3000                    chat_id: "chat-1".to_string(),
3001                    lark_app_id: "app-1".to_string(),
3002                }),
3003            },
3004        )
3005        .unwrap();
3006
3007        // Write attemptCreated + waitCreated + waitResolved (approved) but NO terminal.
3008        // This simulates a crash after the wait was resolved but before the terminal
3009        // event was written.
3010        let gate_activity_id = format!("{}::gate::gate", run_id);
3011        let gate_attempt_id = format!("{}::gate::gate::att-1", run_id);
3012        {
3013            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3014            let _ = log
3015                .append(EventDraft {
3016                    event_type: "attemptCreated".to_string(),
3017                    actor: WorkflowActor::Scheduler,
3018                    payload: serde_json::json!({
3019                        "nodeId": "gate",
3020                        "activityId": &gate_activity_id,
3021                        "attemptId": &gate_attempt_id,
3022                        "attemptNumber": 1,
3023                        "inputRef": {
3024                            "outputHash": "sha256:aa",
3025                            "outputPath": "/tmp/aa",
3026                            "outputBytes": 2,
3027                            "outputSchemaVersion": 1,
3028                            "contentType": "application/json"
3029                        }
3030                    }),
3031                    timestamp: None,
3032                    payload_hash: None,
3033                })
3034                .unwrap();
3035            let _ = log
3036                .append(EventDraft {
3037                    event_type: "waitCreated".to_string(),
3038                    actor: WorkflowActor::Scheduler,
3039                    payload: serde_json::json!({
3040                        "activityId": &gate_activity_id,
3041                        "attemptId": &gate_attempt_id,
3042                        "nodeId": "gate",
3043                        "waitKind": "human-gate",
3044                    }),
3045                    timestamp: None,
3046                    payload_hash: None,
3047                })
3048                .unwrap();
3049            let _ = log
3050                .append(EventDraft {
3051                    event_type: "waitResolved".to_string(),
3052                    actor: WorkflowActor::Human,
3053                    payload: serde_json::json!({
3054                        "activityId": &gate_activity_id,
3055                        "resolution": "approved",
3056                        "by": "admin",
3057                        "comment": "go ahead",
3058                    }),
3059                    timestamp: None,
3060                    payload_hash: None,
3061                })
3062                .unwrap();
3063        }
3064
3065        let mut rt = WorkflowRuntimeContext {
3066            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
3067            def: WorkflowDefinition {
3068                workflow_id: "flow-resolved-wait".to_string(),
3069                version: 1,
3070                params: None,
3071                defaults: None,
3072                nodes: BTreeMap::from([
3073                    (
3074                        "gate".to_string(),
3075                        WorkflowNode::HostExecutor(HostExecutorNode {
3076                            base: NodeBase {
3077                                description: None,
3078                                depends: None,
3079                                human_gate: Some(crate::workflow_definition::HumanGate {
3080                                    stage: "gate".to_string(),
3081                                    prompt: Value::String("approve?".to_string()),
3082                                    approvers: Some(vec!["admin".to_string()]),
3083                                    deadline_ms: None,
3084                                    on_timeout: None,
3085                                }),
3086                                retry_policy: None,
3087                                timeout_ms: None,
3088                                max_output_bytes: None,
3089                                output_schema: None,
3090                                unsafe_allow_ungated: Some(true),
3091                            },
3092                            executor: "beam-schedule".to_string(),
3093                            input: serde_json::json!({"prompt":"approve"}),
3094                        }),
3095                    ),
3096                    (
3097                        "sink".to_string(),
3098                        WorkflowNode::Subagent(SubagentNode {
3099                            base: NodeBase {
3100                                description: None,
3101                                depends: Some(vec!["gate".to_string()]),
3102                                human_gate: None,
3103                                retry_policy: None,
3104                                timeout_ms: None,
3105                                max_output_bytes: None,
3106                                output_schema: None,
3107                                unsafe_allow_ungated: None,
3108                            },
3109                            bot: "bot-a".to_string(),
3110                            prompt: Value::String("done".to_string()),
3111                            working_dir: None,
3112                            model_overrides: None,
3113                            tool_policy: None,
3114                        }),
3115                    ),
3116                ]),
3117            },
3118            runs_base_dir: paths.workflow_runs_dir(),
3119        };
3120
3121        // Verify snapshot shows resolved wait in wait_resolutions, NOT in waits
3122        let snap = read_snapshot(&rt).await.unwrap();
3123        assert!(
3124            snap.dangling.waits.is_empty(),
3125            "expected no open waits after resolution, got {:?}",
3126            snap.dangling.waits
3127        );
3128        assert!(
3129            !snap.dangling.wait_resolutions.is_empty(),
3130            "expected resolved wait in wait_resolutions, got {:?}",
3131            snap.dangling.wait_resolutions
3132        );
3133        assert_eq!(
3134            snap.dangling.wait_resolutions,
3135            vec![gate_activity_id.clone()],
3136            "expected gate activity in wait_resolutions"
3137        );
3138
3139        let mut hooks = FakeHooks;
3140        let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
3141
3142        // run_loop should have materialized the terminal event (activitySucceeded
3143        // for approved wait), allowing the orchestrator to proceed past the gate.
3144        let final_snap = read_snapshot(&rt).await.unwrap();
3145        assert!(
3146            final_snap.dangling.wait_resolutions.is_empty(),
3147            "expected no remaining wait_resolutions after recovery, got {:?}",
3148            final_snap.dangling.wait_resolutions
3149        );
3150
3151        let events = rt.log.read_all().unwrap();
3152        let recovered_terminal = events.iter().any(|e| {
3153            e.event_type == "activitySucceeded"
3154                && e.payload.get("activityId").and_then(Value::as_str) == Some(&gate_activity_id)
3155                && e.actor == WorkflowActor::Scheduler
3156        });
3157        assert!(
3158            recovered_terminal,
3159            "expected a scheduler activitySucceeded for the gate after wait resolution recovery"
3160        );
3161
3162        // The loop should either have progressed (ticks > 0) or stopped at terminal.
3163        assert!(
3164            result.ticks > 0 || matches!(result.reason, RunLoopStopReason::Terminal),
3165            "expected progress after wait resolution recovery; reason={:?} ticks={}",
3166            result.reason,
3167            result.ticks
3168        );
3169
3170        let _ = fs::remove_dir_all(&run_dir);
3171    }
3172
3173    /// Verify that cancelRequested (run) propagation writes activityCanceled
3174    /// for an open human-gate activity before writing runCanceled.
3175    #[tokio::test]
3176    async fn cancel_propagation_writes_activity_canceled_before_run_canceled() {
3177        let run_dir = temp_run_dir("cancel-propagate");
3178        let _ = fs::remove_dir_all(&run_dir);
3179        fs::create_dir_all(run_dir.join("blobs")).unwrap();
3180        let paths = crate::BeamPaths::from_root(run_dir.clone());
3181        let run_id = "run-cancel-propagate";
3182        crate::bootstrap_workflow_run(
3183            &paths,
3184            crate::BootstrapWorkflowRunInput {
3185                run_id,
3186                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"]}}}"#,
3187                expected_workflow_id: Some("flow-cancel-propagate"),
3188                params: &BTreeMap::new(),
3189                initiator: "cli",
3190                chat_binding: Some(RunChatBinding {
3191                    chat_id: "chat-1".to_string(),
3192                    lark_app_id: "app-1".to_string(),
3193                }),
3194            },
3195        )
3196        .unwrap();
3197
3198        // First run_loop tick: dispatches the human-gate (creates a wait).
3199        let gate_activity_id = format!("{}::gate::gate", run_id);
3200        {
3201            let mut rt = WorkflowRuntimeContext {
3202                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
3203                def: WorkflowDefinition {
3204                    workflow_id: "flow-cancel-propagate".to_string(),
3205                    version: 1,
3206                    params: None,
3207                    defaults: None,
3208                    nodes: BTreeMap::from([
3209                        (
3210                            "gate".to_string(),
3211                            WorkflowNode::HostExecutor(HostExecutorNode {
3212                                base: NodeBase {
3213                                    description: None,
3214                                    depends: None,
3215                                    human_gate: Some(crate::workflow_definition::HumanGate {
3216                                        stage: "gate".to_string(),
3217                                        prompt: Value::String("approve?".to_string()),
3218                                        approvers: Some(vec!["admin".to_string()]),
3219                                        deadline_ms: None,
3220                                        on_timeout: None,
3221                                    }),
3222                                    retry_policy: None,
3223                                    timeout_ms: None,
3224                                    max_output_bytes: None,
3225                                    output_schema: None,
3226                                    unsafe_allow_ungated: Some(true),
3227                                },
3228                                executor: "beam-schedule".to_string(),
3229                                input: serde_json::json!({"prompt":"approve"}),
3230                            }),
3231                        ),
3232                        (
3233                            "sink".to_string(),
3234                            WorkflowNode::Subagent(SubagentNode {
3235                                base: NodeBase {
3236                                    description: None,
3237                                    depends: Some(vec!["gate".to_string()]),
3238                                    human_gate: None,
3239                                    retry_policy: None,
3240                                    timeout_ms: None,
3241                                    max_output_bytes: None,
3242                                    output_schema: None,
3243                                    unsafe_allow_ungated: None,
3244                                },
3245                                bot: "bot-a".to_string(),
3246                                prompt: Value::String("done".to_string()),
3247                                working_dir: None,
3248                                model_overrides: None,
3249                                tool_policy: None,
3250                            }),
3251                        ),
3252                    ]),
3253                },
3254                runs_base_dir: paths.workflow_runs_dir(),
3255            };
3256            let mut hooks = FakeHooks;
3257            let result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
3258            assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
3259        }
3260
3261        // Write cancelRequested (run).
3262        {
3263            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3264            let _ = crate::request_cancel(
3265                &mut log,
3266                crate::RequestCancelInput {
3267                    target: serde_json::json!({
3268                        "kind": "run",
3269                        "runId": run_id,
3270                    }),
3271                    reason: "test cancel".to_string(),
3272                    by: "tester".to_string(),
3273                },
3274                WorkflowActor::Human,
3275            )
3276            .await
3277            .unwrap();
3278        }
3279
3280        // Second run_loop: should propagate cancel (activityCanceled → runCanceled).
3281        {
3282            let mut rt = WorkflowRuntimeContext {
3283                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
3284                def: WorkflowDefinition {
3285                    workflow_id: "flow-cancel-propagate".to_string(),
3286                    version: 1,
3287                    params: None,
3288                    defaults: None,
3289                    nodes: BTreeMap::from([
3290                        (
3291                            "gate".to_string(),
3292                            WorkflowNode::HostExecutor(HostExecutorNode {
3293                                base: NodeBase {
3294                                    description: None,
3295                                    depends: None,
3296                                    human_gate: Some(crate::workflow_definition::HumanGate {
3297                                        stage: "gate".to_string(),
3298                                        prompt: Value::String("approve?".to_string()),
3299                                        approvers: Some(vec!["admin".to_string()]),
3300                                        deadline_ms: None,
3301                                        on_timeout: None,
3302                                    }),
3303                                    retry_policy: None,
3304                                    timeout_ms: None,
3305                                    max_output_bytes: None,
3306                                    output_schema: None,
3307                                    unsafe_allow_ungated: Some(true),
3308                                },
3309                                executor: "beam-schedule".to_string(),
3310                                input: serde_json::json!({"prompt":"approve"}),
3311                            }),
3312                        ),
3313                        (
3314                            "sink".to_string(),
3315                            WorkflowNode::Subagent(SubagentNode {
3316                                base: NodeBase {
3317                                    description: None,
3318                                    depends: Some(vec!["gate".to_string()]),
3319                                    human_gate: None,
3320                                    retry_policy: None,
3321                                    timeout_ms: None,
3322                                    max_output_bytes: None,
3323                                    output_schema: None,
3324                                    unsafe_allow_ungated: None,
3325                                },
3326                                bot: "bot-a".to_string(),
3327                                prompt: Value::String("done".to_string()),
3328                                working_dir: None,
3329                                model_overrides: None,
3330                                tool_policy: None,
3331                            }),
3332                        ),
3333                    ]),
3334                },
3335                runs_base_dir: paths.workflow_runs_dir(),
3336            };
3337            let mut hooks = FakeHooks;
3338            let result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
3339            assert_eq!(result.reason, RunLoopStopReason::Terminal);
3340        }
3341
3342        // Verify event order: activityCanceled appears before runCanceled.
3343        let log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3344        let events = log.read_all().unwrap();
3345        let has_cancel_requested = events.iter().any(|e| e.event_type == "cancelRequested");
3346        assert!(has_cancel_requested, "should have cancelRequested");
3347
3348        // Find positions of activityCanceled (for the gate) and runCanceled.
3349        let mut activity_canceled_pos: Option<usize> = None;
3350        let mut run_canceled_pos: Option<usize> = None;
3351        for (i, e) in events.iter().enumerate() {
3352            if e.event_type == "activityCanceled"
3353                && e.payload.get("activityId").and_then(Value::as_str) == Some(&gate_activity_id)
3354            {
3355                activity_canceled_pos = Some(i);
3356            }
3357            if e.event_type == "runCanceled" {
3358                run_canceled_pos = Some(i);
3359            }
3360        }
3361
3362        assert!(
3363            activity_canceled_pos.is_some(),
3364            "should have activityCanceled for the gate activity"
3365        );
3366        assert!(run_canceled_pos.is_some(), "should have runCanceled");
3367        assert!(
3368            activity_canceled_pos.unwrap() < run_canceled_pos.unwrap(),
3369            "activityCanceled ({}) must appear before runCanceled ({})",
3370            activity_canceled_pos.unwrap(),
3371            run_canceled_pos.unwrap()
3372        );
3373
3374        let _ = fs::remove_dir_all(&run_dir);
3375    }
3376
3377    /// Verify that cancel propagation is idempotent: running check_pending_cancels
3378    /// again on an already-cancelled run does not write duplicate events.
3379    #[tokio::test]
3380    async fn cancel_propagation_is_idempotent_after_run_is_cancelled() {
3381        let run_dir = temp_run_dir("cancel-idempotent");
3382        let _ = fs::remove_dir_all(&run_dir);
3383        fs::create_dir_all(run_dir.join("blobs")).unwrap();
3384        let paths = crate::BeamPaths::from_root(run_dir.clone());
3385        let run_id = "run-cancel-idempotent";
3386        crate::bootstrap_workflow_run(
3387            &paths,
3388            crate::BootstrapWorkflowRunInput {
3389                run_id,
3390                workflow_json: r#"{"workflowId":"flow-cancel-idem","version":1,"nodes":{"a":{"type":"subagent","bot":"bot-a","prompt":"hello"}}}"#,
3391                expected_workflow_id: Some("flow-cancel-idem"),
3392                params: &BTreeMap::new(),
3393                initiator: "cli",
3394                chat_binding: Some(RunChatBinding {
3395                    chat_id: "chat-1".to_string(),
3396                    lark_app_id: "app-1".to_string(),
3397                }),
3398            },
3399        )
3400        .unwrap();
3401
3402        // Write cancelRequested before any dispatch.
3403        {
3404            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3405            let _ = crate::request_cancel(
3406                &mut log,
3407                crate::RequestCancelInput {
3408                    target: serde_json::json!({
3409                        "kind": "run",
3410                        "runId": run_id,
3411                    }),
3412                    reason: "test cancel".to_string(),
3413                    by: "tester".to_string(),
3414                },
3415                WorkflowActor::Human,
3416            )
3417            .await
3418            .unwrap();
3419        }
3420
3421        // First run_loop: should write runCanceled.
3422        {
3423            let mut rt = WorkflowRuntimeContext {
3424                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
3425                def: WorkflowDefinition {
3426                    workflow_id: "flow-cancel-idem".to_string(),
3427                    version: 1,
3428                    params: None,
3429                    defaults: None,
3430                    nodes: BTreeMap::from([(
3431                        "a".to_string(),
3432                        WorkflowNode::Subagent(SubagentNode {
3433                            base: NodeBase {
3434                                description: None,
3435                                depends: None,
3436                                human_gate: None,
3437                                retry_policy: None,
3438                                timeout_ms: None,
3439                                max_output_bytes: None,
3440                                output_schema: None,
3441                                unsafe_allow_ungated: None,
3442                            },
3443                            bot: "bot-a".to_string(),
3444                            prompt: Value::String("hello".to_string()),
3445                            working_dir: None,
3446                            model_overrides: None,
3447                            tool_policy: None,
3448                        }),
3449                    )]),
3450                },
3451                runs_base_dir: paths.workflow_runs_dir(),
3452            };
3453            let mut hooks = FakeHooks;
3454            let result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
3455            assert_eq!(result.reason, RunLoopStopReason::Terminal);
3456        }
3457
3458        // Second run_loop: should be idempotent (no duplicate runCanceled).
3459        let run_canceled_count_before: usize;
3460        {
3461            let log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3462            let events = log.read_all().unwrap();
3463            run_canceled_count_before = events
3464                .iter()
3465                .filter(|e| e.event_type == "runCanceled")
3466                .count();
3467            assert_eq!(
3468                run_canceled_count_before, 1,
3469                "should have exactly 1 runCanceled after first propagation"
3470            );
3471        }
3472
3473        {
3474            let mut rt = WorkflowRuntimeContext {
3475                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
3476                def: WorkflowDefinition {
3477                    workflow_id: "flow-cancel-idem".to_string(),
3478                    version: 1,
3479                    params: None,
3480                    defaults: None,
3481                    nodes: BTreeMap::from([(
3482                        "a".to_string(),
3483                        WorkflowNode::Subagent(SubagentNode {
3484                            base: NodeBase {
3485                                description: None,
3486                                depends: None,
3487                                human_gate: None,
3488                                retry_policy: None,
3489                                timeout_ms: None,
3490                                max_output_bytes: None,
3491                                output_schema: None,
3492                                unsafe_allow_ungated: None,
3493                            },
3494                            bot: "bot-a".to_string(),
3495                            prompt: Value::String("hello".to_string()),
3496                            working_dir: None,
3497                            model_overrides: None,
3498                            tool_policy: None,
3499                        }),
3500                    )]),
3501                },
3502                runs_base_dir: paths.workflow_runs_dir(),
3503            };
3504            let mut hooks = FakeHooks;
3505            let _ = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
3506        }
3507
3508        let log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3509        let events = log.read_all().unwrap();
3510        let run_canceled_count_after = events
3511            .iter()
3512            .filter(|e| e.event_type == "runCanceled")
3513            .count();
3514        assert_eq!(
3515            run_canceled_count_after, 1,
3516            "second run_loop should not produce duplicate runCanceled"
3517        );
3518
3519        let _ = fs::remove_dir_all(&run_dir);
3520    }
3521
3522    // --- Loop lifecycle action/event tests (Task 8.1) ---
3523
3524    /// Returns a minimal workflow JSON with a single subagent node.
3525    fn min_workflow_json(workflow_id: &str, node_id: &str) -> String {
3526        format!(
3527            r#"{{"workflowId":"{workflow_id}","version":1,"nodes":{{"{node_id}":{{"type":"subagent","bot":"bot-x","prompt":"ok"}}}}}}"#
3528        )
3529    }
3530
3531    #[tokio::test]
3532    async fn start_loop_writes_loop_started_event() {
3533        let run_dir = temp_run_dir("loop-start");
3534        let _ = fs::remove_dir_all(&run_dir);
3535        fs::create_dir_all(run_dir.join("blobs")).unwrap();
3536        let paths = crate::BeamPaths::from_root(run_dir.clone());
3537        let run_id = "run-loop-start";
3538        crate::bootstrap_workflow_run(
3539            &paths,
3540            crate::BootstrapWorkflowRunInput {
3541                run_id,
3542                workflow_json: &min_workflow_json("flow-loop-start", "a"),
3543                expected_workflow_id: Some("flow-loop-start"),
3544                params: &BTreeMap::new(),
3545                initiator: "cli",
3546                chat_binding: None,
3547            },
3548        )
3549        .unwrap();
3550
3551        let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3552        let action = OrchestratorAction::StartLoop {
3553            node_id: "loop-1".to_string(),
3554            max_iterations: 5,
3555        };
3556        start_loop(&mut log, &action).await.unwrap();
3557
3558        let events = log.read_all().unwrap();
3559        let loop_events: Vec<_> = events
3560            .iter()
3561            .filter(|e| e.event_type == "loopStarted")
3562            .collect();
3563        assert_eq!(
3564            loop_events.len(),
3565            1,
3566            "expected exactly one loopStarted event"
3567        );
3568        let ev = loop_events[0];
3569        assert_eq!(ev.payload["loopId"], "loop-1");
3570        assert_eq!(ev.payload["maxIterations"], 5);
3571        assert_eq!(ev.actor, WorkflowActor::Scheduler);
3572
3573        let _ = fs::remove_dir_all(&run_dir);
3574    }
3575
3576    #[tokio::test]
3577    async fn start_loop_iteration_writes_event() {
3578        let run_dir = temp_run_dir("loop-iter-start");
3579        let _ = fs::remove_dir_all(&run_dir);
3580        fs::create_dir_all(run_dir.join("blobs")).unwrap();
3581        let paths = crate::BeamPaths::from_root(run_dir.clone());
3582        let run_id = "run-loop-iter-start";
3583        crate::bootstrap_workflow_run(
3584            &paths,
3585            crate::BootstrapWorkflowRunInput {
3586                run_id,
3587                workflow_json: &min_workflow_json("flow-loop-iter-start", "a"),
3588                expected_workflow_id: Some("flow-loop-iter-start"),
3589                params: &BTreeMap::new(),
3590                initiator: "cli",
3591                chat_binding: None,
3592            },
3593        )
3594        .unwrap();
3595
3596        let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3597        let action = OrchestratorAction::StartLoopIteration {
3598            node_id: "loop-1".to_string(),
3599            iteration: 2,
3600        };
3601        start_loop_iteration(&mut log, &action).await.unwrap();
3602
3603        let events = log.read_all().unwrap();
3604        let loop_events: Vec<_> = events
3605            .iter()
3606            .filter(|e| e.event_type == "loopIterationStarted")
3607            .collect();
3608        assert_eq!(
3609            loop_events.len(),
3610            1,
3611            "expected exactly one loopIterationStarted event"
3612        );
3613        let ev = loop_events[0];
3614        assert_eq!(ev.payload["loopId"], "loop-1");
3615        assert_eq!(ev.payload["iteration"], 2);
3616        assert_eq!(ev.actor, WorkflowActor::Scheduler);
3617
3618        let _ = fs::remove_dir_all(&run_dir);
3619    }
3620
3621    #[tokio::test]
3622    async fn finish_loop_iteration_writes_event() {
3623        let run_dir = temp_run_dir("loop-iter-finish");
3624        let _ = fs::remove_dir_all(&run_dir);
3625        fs::create_dir_all(run_dir.join("blobs")).unwrap();
3626        let paths = crate::BeamPaths::from_root(run_dir.clone());
3627        let run_id = "run-loop-iter-finish";
3628        crate::bootstrap_workflow_run(
3629            &paths,
3630            crate::BootstrapWorkflowRunInput {
3631                run_id,
3632                workflow_json: &min_workflow_json("flow-loop-iter-finish", "a"),
3633                expected_workflow_id: Some("flow-loop-iter-finish"),
3634                params: &BTreeMap::new(),
3635                initiator: "cli",
3636                chat_binding: None,
3637            },
3638        )
3639        .unwrap();
3640
3641        let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3642        let action = OrchestratorAction::FinishLoopIteration {
3643            node_id: "loop-1".to_string(),
3644            iteration: 3,
3645            resolution: "approved".to_string(),
3646            decision_activity_id: Some("run-loop-iter-finish::gate::loop-1".to_string()),
3647            wait_resolved_event_id: None,
3648            by: Some("tester".to_string()),
3649            comment: Some("looks good".to_string()),
3650            timed_out: Some(false),
3651        };
3652        finish_loop_iteration(&mut log, &action).await.unwrap();
3653
3654        let events = log.read_all().unwrap();
3655        let loop_events: Vec<_> = events
3656            .iter()
3657            .filter(|e| e.event_type == "loopIterationFinished")
3658            .collect();
3659        assert_eq!(
3660            loop_events.len(),
3661            1,
3662            "expected exactly one loopIterationFinished event"
3663        );
3664        let ev = loop_events[0];
3665        assert_eq!(ev.payload["loopId"], "loop-1");
3666        assert_eq!(ev.payload["iteration"], 3);
3667        assert_eq!(ev.payload["resolution"], "approved");
3668        assert_eq!(ev.payload["by"], "tester");
3669        assert_eq!(ev.payload["comment"], "looks good");
3670        assert_eq!(ev.payload["timedOut"], false);
3671        assert_eq!(ev.actor, WorkflowActor::Scheduler);
3672
3673        let _ = fs::remove_dir_all(&run_dir);
3674    }
3675
3676    #[tokio::test]
3677    async fn finish_loop_writes_loop_finished_event() {
3678        let run_dir = temp_run_dir("loop-finish");
3679        let _ = fs::remove_dir_all(&run_dir);
3680        fs::create_dir_all(run_dir.join("blobs")).unwrap();
3681        let paths = crate::BeamPaths::from_root(run_dir.clone());
3682        let run_id = "run-loop-finish";
3683        crate::bootstrap_workflow_run(
3684            &paths,
3685            crate::BootstrapWorkflowRunInput {
3686                run_id,
3687                workflow_json: &min_workflow_json("flow-loop-finish", "a"),
3688                expected_workflow_id: Some("flow-loop-finish"),
3689                params: &BTreeMap::new(),
3690                initiator: "cli",
3691                chat_binding: None,
3692            },
3693        )
3694        .unwrap();
3695
3696        let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3697        let output_ref = WorkflowOutputRef {
3698            output_hash: "sha256:abc".to_string(),
3699            output_path: "/tmp/loop-out.json".to_string(),
3700            output_bytes: 10,
3701            output_schema_version: 1,
3702            content_type: Some("application/json".to_string()),
3703        };
3704        let action = OrchestratorAction::FinishLoop {
3705            node_id: "loop-1".to_string(),
3706            final_iteration: 3,
3707            resolution: "approved".to_string(),
3708            output_ref: Some(output_ref.clone()),
3709            error_code: None,
3710            error_class: None,
3711        };
3712        finish_loop(&mut log, &action).await.unwrap();
3713
3714        let events = log.read_all().unwrap();
3715        let loop_events: Vec<_> = events
3716            .iter()
3717            .filter(|e| e.event_type == "loopFinished")
3718            .collect();
3719        assert_eq!(
3720            loop_events.len(),
3721            1,
3722            "expected exactly one loopFinished event"
3723        );
3724        let ev = loop_events[0];
3725        assert_eq!(ev.payload["loopId"], "loop-1");
3726        assert_eq!(ev.payload["finalIteration"], 3);
3727        assert_eq!(ev.payload["resolution"], "approved");
3728        assert_eq!(ev.actor, WorkflowActor::Scheduler);
3729
3730        let _ = fs::remove_dir_all(&run_dir);
3731    }
3732
3733    #[tokio::test]
3734    async fn all_loop_actions_produce_correct_event_sequence() {
3735        // Write a full loop lifecycle and verify event order and types.
3736        let run_dir = temp_run_dir("loop-sequence");
3737        let _ = fs::remove_dir_all(&run_dir);
3738        fs::create_dir_all(run_dir.join("blobs")).unwrap();
3739        let paths = crate::BeamPaths::from_root(run_dir.clone());
3740        let run_id = "run-loop-seq";
3741        crate::bootstrap_workflow_run(
3742            &paths,
3743            crate::BootstrapWorkflowRunInput {
3744                run_id,
3745                workflow_json: &min_workflow_json("flow-loop-seq", "a"),
3746                expected_workflow_id: Some("flow-loop-seq"),
3747                params: &BTreeMap::new(),
3748                initiator: "cli",
3749                chat_binding: None,
3750            },
3751        )
3752        .unwrap();
3753
3754        let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3755
3756        // Start loop
3757        start_loop(
3758            &mut log,
3759            &OrchestratorAction::StartLoop {
3760                node_id: "loop-1".to_string(),
3761                max_iterations: 3,
3762            },
3763        )
3764        .await
3765        .unwrap();
3766
3767        // Iteration 1
3768        start_loop_iteration(
3769            &mut log,
3770            &OrchestratorAction::StartLoopIteration {
3771                node_id: "loop-1".to_string(),
3772                iteration: 1,
3773            },
3774        )
3775        .await
3776        .unwrap();
3777        finish_loop_iteration(
3778            &mut log,
3779            &OrchestratorAction::FinishLoopIteration {
3780                node_id: "loop-1".to_string(),
3781                iteration: 1,
3782                resolution: "approved".to_string(),
3783                decision_activity_id: None,
3784                wait_resolved_event_id: None,
3785                by: None,
3786                comment: None,
3787                timed_out: None,
3788            },
3789        )
3790        .await
3791        .unwrap();
3792
3793        // Iteration 2 (rejected)
3794        start_loop_iteration(
3795            &mut log,
3796            &OrchestratorAction::StartLoopIteration {
3797                node_id: "loop-1".to_string(),
3798                iteration: 2,
3799            },
3800        )
3801        .await
3802        .unwrap();
3803        finish_loop_iteration(
3804            &mut log,
3805            &OrchestratorAction::FinishLoopIteration {
3806                node_id: "loop-1".to_string(),
3807                iteration: 2,
3808                resolution: "rejected".to_string(),
3809                decision_activity_id: Some(format!("{}::gate::loop-1", run_id)),
3810                wait_resolved_event_id: None,
3811                by: Some("reviewer".to_string()),
3812                comment: Some("needs work".to_string()),
3813                timed_out: Some(false),
3814            },
3815        )
3816        .await
3817        .unwrap();
3818
3819        // Finish loop (overall approved after 2 iterations)
3820        let output_ref = WorkflowOutputRef {
3821            output_hash: "sha256:xyz".to_string(),
3822            output_path: "/tmp/xyz.json".to_string(),
3823            output_bytes: 8,
3824            output_schema_version: 1,
3825            content_type: Some("application/json".to_string()),
3826        };
3827        finish_loop(
3828            &mut log,
3829            &OrchestratorAction::FinishLoop {
3830                node_id: "loop-1".to_string(),
3831                final_iteration: 2,
3832                resolution: "approved".to_string(),
3833                output_ref: Some(output_ref.clone()),
3834                error_code: None,
3835                error_class: None,
3836            },
3837        )
3838        .await
3839        .unwrap();
3840
3841        let events = log.read_all().unwrap();
3842        let loop_event_types: Vec<_> = events
3843            .iter()
3844            .filter(|e| e.event_type.starts_with("loop"))
3845            .map(|e| e.event_type.as_str())
3846            .collect();
3847        assert_eq!(
3848            loop_event_types,
3849            vec![
3850                "loopStarted",
3851                "loopIterationStarted",
3852                "loopIterationFinished",
3853                "loopIterationStarted",
3854                "loopIterationFinished",
3855                "loopFinished",
3856            ],
3857            "expected full loop lifecycle events in order"
3858        );
3859
3860        let _ = fs::remove_dir_all(&run_dir);
3861    }
3862
3863    #[tokio::test]
3864    async fn replay_builds_snapshot_loops_from_loop_events() {
3865        // Write loop events directly to the events file, then replay and check
3866        // snapshot.loops.
3867        let run_dir = temp_run_dir("loop-replay");
3868        let _ = fs::remove_dir_all(&run_dir);
3869        fs::create_dir_all(run_dir.join("blobs")).unwrap();
3870        let paths = crate::BeamPaths::from_root(run_dir.clone());
3871        let run_id = "run-loop-replay";
3872        crate::bootstrap_workflow_run(
3873            &paths,
3874            crate::BootstrapWorkflowRunInput {
3875                run_id,
3876                workflow_json: &min_workflow_json("flow-loop-replay", "a"),
3877                expected_workflow_id: Some("flow-loop-replay"),
3878                params: &BTreeMap::new(),
3879                initiator: "cli",
3880                chat_binding: None,
3881            },
3882        )
3883        .unwrap();
3884
3885        // The bootstrap already wrote runCreated + runStarted. Now append loop events directly.
3886        {
3887            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3888            let _ = log
3889                .append(EventDraft {
3890                    event_type: "loopStarted".to_string(),
3891                    actor: WorkflowActor::Scheduler,
3892                    payload: serde_json::json!({
3893                        "loopId": "loop-1",
3894                        "maxIterations": 3,
3895                    }),
3896                    timestamp: None,
3897                    payload_hash: None,
3898                })
3899                .unwrap();
3900            let _ = log
3901                .append(EventDraft {
3902                    event_type: "loopIterationStarted".to_string(),
3903                    actor: WorkflowActor::Scheduler,
3904                    payload: serde_json::json!({
3905                        "loopId": "loop-1",
3906                        "iteration": 1,
3907                    }),
3908                    timestamp: None,
3909                    payload_hash: None,
3910                })
3911                .unwrap();
3912            let _ = log
3913                .append(EventDraft {
3914                    event_type: "loopIterationFinished".to_string(),
3915                    actor: WorkflowActor::Scheduler,
3916                    payload: serde_json::json!({
3917                        "loopId": "loop-1",
3918                        "iteration": 1,
3919                        "resolution": "approved",
3920                    }),
3921                    timestamp: None,
3922                    payload_hash: None,
3923                })
3924                .unwrap();
3925            let _ = log
3926                .append(EventDraft {
3927                    event_type: "loopFinished".to_string(),
3928                    actor: WorkflowActor::Scheduler,
3929                    payload: serde_json::json!({
3930                        "loopId": "loop-1",
3931                        "finalIteration": 1,
3932                        "resolution": "approved",
3933                    }),
3934                    timestamp: None,
3935                    payload_hash: None,
3936                })
3937                .unwrap();
3938        }
3939
3940        // Read snapshot and verify loops
3941        let rt = WorkflowRuntimeContext {
3942            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
3943            def: WorkflowDefinition {
3944                workflow_id: "flow-loop-replay".to_string(),
3945                version: 1,
3946                params: None,
3947                defaults: None,
3948                nodes: BTreeMap::new(),
3949            },
3950            runs_base_dir: paths.workflow_runs_dir(),
3951        };
3952        let snapshot = read_snapshot(&rt).await.unwrap();
3953
3954        let loops = snapshot
3955            .loops
3956            .as_ref()
3957            .expect("snapshot.loops should be Some after loop events");
3958        assert!(
3959            loops.contains_key("loop-1"),
3960            "loop-1 should be in snapshot.loops"
3961        );
3962
3963        let loop_state = loops.get("loop-1").unwrap();
3964        assert_eq!(loop_state.loop_id, "loop-1");
3965        assert_eq!(
3966            loop_state.status,
3967            LoopStatus::Succeeded,
3968            "loop should be Succeeded (approved)"
3969        );
3970        assert_eq!(loop_state.max_iterations, 3);
3971        assert_eq!(loop_state.iteration, 1, "finalIteration should be 1");
3972        assert_eq!(
3973            loop_state.iterations.len(),
3974            1,
3975            "should have 1 iteration recorded"
3976        );
3977
3978        let iteration = &loop_state.iterations[0];
3979        assert_eq!(iteration.iteration, 1);
3980        assert_eq!(iteration.status, LoopIterationStatus::Approved);
3981
3982        let _ = fs::remove_dir_all(&run_dir);
3983    }
3984
3985    #[tokio::test]
3986    async fn replay_loop_failed_sets_status_and_dangling_iteration() {
3987        // Simulate a loop that fails midway: loopStarted → iter1 start/finish
3988        // (approved) → iter2 started → loopFinished with resolution=failed.
3989        // The inflight iteration should be marked Failed.
3990        let run_dir = temp_run_dir("loop-failed-replay");
3991        let _ = fs::remove_dir_all(&run_dir);
3992        fs::create_dir_all(run_dir.join("blobs")).unwrap();
3993        let paths = crate::BeamPaths::from_root(run_dir.clone());
3994        let run_id = "run-loop-failed-replay";
3995        crate::bootstrap_workflow_run(
3996            &paths,
3997            crate::BootstrapWorkflowRunInput {
3998                run_id,
3999                workflow_json: &min_workflow_json("flow-loop-failed-replay", "a"),
4000                expected_workflow_id: Some("flow-loop-failed-replay"),
4001                params: &BTreeMap::new(),
4002                initiator: "cli",
4003                chat_binding: None,
4004            },
4005        )
4006        .unwrap();
4007
4008        {
4009            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4010            let _ = log
4011                .append(EventDraft {
4012                    event_type: "loopStarted".to_string(),
4013                    actor: WorkflowActor::Scheduler,
4014                    payload: serde_json::json!({
4015                        "loopId": "loop-1",
4016                        "maxIterations": 5,
4017                    }),
4018                    timestamp: None,
4019                    payload_hash: None,
4020                })
4021                .unwrap();
4022            let _ = log
4023                .append(EventDraft {
4024                    event_type: "loopIterationStarted".to_string(),
4025                    actor: WorkflowActor::Scheduler,
4026                    payload: serde_json::json!({
4027                        "loopId": "loop-1",
4028                        "iteration": 1,
4029                    }),
4030                    timestamp: None,
4031                    payload_hash: None,
4032                })
4033                .unwrap();
4034            let _ = log
4035                .append(EventDraft {
4036                    event_type: "loopIterationFinished".to_string(),
4037                    actor: WorkflowActor::Scheduler,
4038                    payload: serde_json::json!({
4039                        "loopId": "loop-1",
4040                        "iteration": 1,
4041                        "resolution": "approved",
4042                    }),
4043                    timestamp: None,
4044                    payload_hash: None,
4045                })
4046                .unwrap();
4047            let _ = log
4048                .append(EventDraft {
4049                    event_type: "loopIterationStarted".to_string(),
4050                    actor: WorkflowActor::Scheduler,
4051                    payload: serde_json::json!({
4052                        "loopId": "loop-1",
4053                        "iteration": 2,
4054                    }),
4055                    timestamp: None,
4056                    payload_hash: None,
4057                })
4058                .unwrap();
4059            // Loop finishes with failure while iteration 2 is still running
4060            let _ = log
4061                .append(EventDraft {
4062                    event_type: "loopFinished".to_string(),
4063                    actor: WorkflowActor::Scheduler,
4064                    payload: serde_json::json!({
4065                        "loopId": "loop-1",
4066                        "finalIteration": 2,
4067                        "resolution": "failed",
4068                        "errorCode": "LoopFailedMidIteration",
4069                        "errorClass": "fatal",
4070                    }),
4071                    timestamp: None,
4072                    payload_hash: None,
4073                })
4074                .unwrap();
4075        }
4076
4077        let rt = WorkflowRuntimeContext {
4078            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4079            def: WorkflowDefinition {
4080                workflow_id: "flow-loop-failed-replay".to_string(),
4081                version: 1,
4082                params: None,
4083                defaults: None,
4084                nodes: BTreeMap::new(),
4085            },
4086            runs_base_dir: paths.workflow_runs_dir(),
4087        };
4088        let snapshot = read_snapshot(&rt).await.unwrap();
4089
4090        let loops = snapshot
4091            .loops
4092            .as_ref()
4093            .expect("snapshot.loops should be Some after loop events");
4094        let loop_state = loops.get("loop-1").unwrap();
4095        assert_eq!(
4096            loop_state.status,
4097            LoopStatus::Failed,
4098            "loop should be Failed"
4099        );
4100        assert_eq!(loop_state.error_class.as_deref(), Some("fatal"));
4101        assert_eq!(
4102            loop_state.error_code.as_deref(),
4103            Some("LoopFailedMidIteration")
4104        );
4105        assert_eq!(loop_state.iteration, 2);
4106        assert_eq!(loop_state.iterations.len(), 2);
4107
4108        // Iteration 1 should be Approved
4109        let iter1 = loop_state
4110            .iterations
4111            .iter()
4112            .find(|it| it.iteration == 1)
4113            .unwrap();
4114        assert_eq!(iter1.status, LoopIterationStatus::Approved);
4115
4116        // Iteration 2 should be Failed (inflight → Failed on loop finish failure)
4117        let iter2 = loop_state
4118            .iterations
4119            .iter()
4120            .find(|it| it.iteration == 2)
4121            .unwrap();
4122        assert_eq!(
4123            iter2.status,
4124            LoopIterationStatus::Failed,
4125            "inflight iteration should be Failed"
4126        );
4127
4128        let _ = fs::remove_dir_all(&run_dir);
4129    }
4130
4131    // ── Task 8.2: loop dispatch pass tests ──
4132
4133    /// Build a minimal code-review-loop WorkflowDefinition for testing.
4134    fn code_review_loop_def() -> WorkflowDefinition {
4135        WorkflowDefinition {
4136            workflow_id: "code-review-loop".to_string(),
4137            version: 1,
4138            params: None,
4139            defaults: None,
4140            nodes: BTreeMap::from([
4141                (
4142                    "implement".to_string(),
4143                    WorkflowNode::Subagent(SubagentNode {
4144                        base: NodeBase {
4145                            description: None,
4146                            depends: None,
4147                            human_gate: None,
4148                            retry_policy: None,
4149                            timeout_ms: None,
4150                            max_output_bytes: None,
4151                            output_schema: None,
4152                            unsafe_allow_ungated: None,
4153                        },
4154                        bot: "bot-a".to_string(),
4155                        prompt: Value::String("implement".to_string()),
4156                        working_dir: None,
4157                        model_overrides: None,
4158                        tool_policy: None,
4159                    }),
4160                ),
4161                (
4162                    "review".to_string(),
4163                    WorkflowNode::Subagent(SubagentNode {
4164                        base: NodeBase {
4165                            description: None,
4166                            depends: Some(vec!["implement".to_string()]),
4167                            human_gate: None,
4168                            retry_policy: None,
4169                            timeout_ms: None,
4170                            max_output_bytes: None,
4171                            output_schema: None,
4172                            unsafe_allow_ungated: None,
4173                        },
4174                        bot: "bot-a".to_string(),
4175                        prompt: Value::String("review".to_string()),
4176                        working_dir: None,
4177                        model_overrides: None,
4178                        tool_policy: None,
4179                    }),
4180                ),
4181                (
4182                    "reviewDecision".to_string(),
4183                    WorkflowNode::Decision(DecisionNode {
4184                        base: NodeBase {
4185                            description: None,
4186                            depends: Some(vec!["review".to_string()]),
4187                            human_gate: Some(HumanGate {
4188                                stage: "before".to_string(),
4189                                prompt: Value::String("approve?".to_string()),
4190                                approvers: None,
4191                                deadline_ms: None,
4192                                on_timeout: None,
4193                            }),
4194                            retry_policy: None,
4195                            timeout_ms: None,
4196                            max_output_bytes: None,
4197                            output_schema: None,
4198                            unsafe_allow_ungated: None,
4199                        },
4200                    }),
4201                ),
4202                (
4203                    "review-loop".to_string(),
4204                    WorkflowNode::Loop(LoopNode {
4205                        base: NodeBase {
4206                            description: None,
4207                            depends: None,
4208                            human_gate: None,
4209                            retry_policy: None,
4210                            timeout_ms: None,
4211                            max_output_bytes: None,
4212                            output_schema: None,
4213                            unsafe_allow_ungated: None,
4214                        },
4215                        max_iterations: 3,
4216                        body: vec![
4217                            "implement".to_string(),
4218                            "review".to_string(),
4219                            "reviewDecision".to_string(),
4220                        ],
4221                        terminate: LoopTerminate {
4222                            node: "reviewDecision".to_string(),
4223                            via: "humanGate".to_string(),
4224                        },
4225                        output: Some(LoopOutputProjection {
4226                            from: "implement".to_string(),
4227                        }),
4228                    }),
4229                ),
4230            ]),
4231        }
4232    }
4233
4234    #[tokio::test]
4235    async fn loop_depends_met_produces_start_loop_and_first_iteration() {
4236        // Verify that when a loop node's depends are satisfied, the orchestrator
4237        // emits StartLoop + StartLoopIteration(1).
4238        let run_dir = temp_run_dir("loop-depends");
4239        let _ = fs::remove_dir_all(&run_dir);
4240        fs::create_dir_all(run_dir.join("blobs")).unwrap();
4241        let paths = crate::BeamPaths::from_root(run_dir.clone());
4242        let params = BTreeMap::new();
4243        let run_id = "run-loop-depends";
4244        crate::bootstrap_workflow_run(
4245            &paths,
4246            crate::BootstrapWorkflowRunInput {
4247                run_id,
4248                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"}}}}"#,
4249                expected_workflow_id: Some("flow-loop-dep"),
4250                params: &params,
4251                initiator: "cli",
4252                chat_binding: None,
4253            },
4254        )
4255        .unwrap();
4256
4257        // First tick: dispatch "pre" (the loop's dependency).
4258        {
4259            let mut rt = WorkflowRuntimeContext {
4260                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4261                def: WorkflowDefinition {
4262                    workflow_id: "flow-loop-dep".to_string(),
4263                    version: 1,
4264                    params: None,
4265                    defaults: None,
4266                    nodes: BTreeMap::from([
4267                        (
4268                            "pre".to_string(),
4269                            WorkflowNode::Subagent(SubagentNode {
4270                                base: NodeBase {
4271                                    description: None,
4272                                    depends: None,
4273                                    human_gate: None,
4274                                    retry_policy: None,
4275                                    timeout_ms: None,
4276                                    max_output_bytes: None,
4277                                    output_schema: None,
4278                                    unsafe_allow_ungated: None,
4279                                },
4280                                bot: "bot-x".to_string(),
4281                                prompt: Value::String("setup".to_string()),
4282                                working_dir: None,
4283                                model_overrides: None,
4284                                tool_policy: None,
4285                            }),
4286                        ),
4287                        (
4288                            "dec".to_string(),
4289                            WorkflowNode::Decision(DecisionNode {
4290                                base: NodeBase {
4291                                    description: None,
4292                                    depends: None,
4293                                    human_gate: None,
4294                                    retry_policy: None,
4295                                    timeout_ms: None,
4296                                    max_output_bytes: None,
4297                                    output_schema: None,
4298                                    unsafe_allow_ungated: None,
4299                                },
4300                            }),
4301                        ),
4302                        (
4303                            "rl".to_string(),
4304                            WorkflowNode::Loop(LoopNode {
4305                                base: NodeBase {
4306                                    description: None,
4307                                    depends: Some(vec!["pre".to_string()]),
4308                                    human_gate: None,
4309                                    retry_policy: None,
4310                                    timeout_ms: None,
4311                                    max_output_bytes: None,
4312                                    output_schema: None,
4313                                    unsafe_allow_ungated: None,
4314                                },
4315                                max_iterations: 3,
4316                                body: vec!["dec".to_string()],
4317                                terminate: LoopTerminate {
4318                                    node: "dec".to_string(),
4319                                    via: "humanGate".to_string(),
4320                                },
4321                                output: Some(LoopOutputProjection {
4322                                    from: "dec".to_string(),
4323                                }),
4324                            }),
4325                        ),
4326                    ]),
4327                },
4328                runs_base_dir: paths.workflow_runs_dir(),
4329            };
4330            let mut hooks = FakeHooks;
4331            let _result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
4332            assert!(
4333                _result.ticks > 0 || matches!(_result.reason, RunLoopStopReason::Terminal),
4334                "expected pre dispatch; reason={:?}",
4335                _result.reason
4336            );
4337        }
4338
4339        // Now "pre" should be succeeded, so the loop can start.
4340        {
4341            let mut rt = WorkflowRuntimeContext {
4342                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4343                def: WorkflowDefinition {
4344                    workflow_id: "flow-loop-dep".to_string(),
4345                    version: 1,
4346                    params: None,
4347                    defaults: None,
4348                    nodes: BTreeMap::from([
4349                        (
4350                            "pre".to_string(),
4351                            WorkflowNode::Subagent(SubagentNode {
4352                                base: NodeBase {
4353                                    description: None,
4354                                    depends: None,
4355                                    human_gate: None,
4356                                    retry_policy: None,
4357                                    timeout_ms: None,
4358                                    max_output_bytes: None,
4359                                    output_schema: None,
4360                                    unsafe_allow_ungated: None,
4361                                },
4362                                bot: "bot-x".to_string(),
4363                                prompt: Value::String("setup".to_string()),
4364                                working_dir: None,
4365                                model_overrides: None,
4366                                tool_policy: None,
4367                            }),
4368                        ),
4369                        (
4370                            "dec".to_string(),
4371                            WorkflowNode::Decision(DecisionNode {
4372                                base: NodeBase {
4373                                    description: None,
4374                                    depends: None,
4375                                    human_gate: None,
4376                                    retry_policy: None,
4377                                    timeout_ms: None,
4378                                    max_output_bytes: None,
4379                                    output_schema: None,
4380                                    unsafe_allow_ungated: None,
4381                                },
4382                            }),
4383                        ),
4384                        (
4385                            "rl".to_string(),
4386                            WorkflowNode::Loop(LoopNode {
4387                                base: NodeBase {
4388                                    description: None,
4389                                    depends: Some(vec!["pre".to_string()]),
4390                                    human_gate: None,
4391                                    retry_policy: None,
4392                                    timeout_ms: None,
4393                                    max_output_bytes: None,
4394                                    output_schema: None,
4395                                    unsafe_allow_ungated: None,
4396                                },
4397                                max_iterations: 3,
4398                                body: vec!["dec".to_string()],
4399                                terminate: LoopTerminate {
4400                                    node: "dec".to_string(),
4401                                    via: "humanGate".to_string(),
4402                                },
4403                                output: Some(LoopOutputProjection {
4404                                    from: "dec".to_string(),
4405                                }),
4406                            }),
4407                        ),
4408                    ]),
4409                },
4410                runs_base_dir: paths.workflow_runs_dir(),
4411            };
4412            let mut hooks = FakeHooks;
4413            let _result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
4414
4415            let events = rt.log.read_all().unwrap();
4416            let loop_started = events.iter().any(|e| {
4417                e.event_type == "loopStarted"
4418                    && e.payload.get("loopId").and_then(Value::as_str) == Some("rl")
4419            });
4420            let iter_started = events.iter().any(|e| {
4421                e.event_type == "loopIterationStarted"
4422                    && e.payload.get("loopId").and_then(Value::as_str) == Some("rl")
4423                    && e.payload.get("iteration").and_then(Value::as_u64) == Some(1)
4424            });
4425            assert!(loop_started, "expected loopStarted event for rl");
4426            assert!(iter_started, "expected loopIterationStarted with iteration 1 for rl");
4427        }
4428
4429        let _ = fs::remove_dir_all(&run_dir);
4430    }
4431
4432    #[tokio::test]
4433    async fn code_review_loop_reaches_human_gate_wait_with_correct_activity_id() {
4434        // Drive the code-review-loop workflow via run_loop until it reaches an
4435        // open human-gate wait.  Verify the activity-id format.
4436        let run_dir = temp_run_dir("crl-gate");
4437        let _ = fs::remove_dir_all(&run_dir);
4438        fs::create_dir_all(run_dir.join("blobs")).unwrap();
4439        let paths = crate::BeamPaths::from_root(run_dir.clone());
4440        let run_id = "run-crl-gate";
4441        let def = code_review_loop_def();
4442        let workflow_json = serde_json::to_string(&def).unwrap();
4443        crate::bootstrap_workflow_run(
4444            &paths,
4445            crate::BootstrapWorkflowRunInput {
4446                run_id,
4447                workflow_json: &workflow_json,
4448                expected_workflow_id: Some("code-review-loop"),
4449                params: &BTreeMap::new(),
4450                initiator: "cli",
4451                chat_binding: Some(RunChatBinding {
4452                    chat_id: "chat-1".to_string(),
4453                    lark_app_id: "app-1".to_string(),
4454                }),
4455            },
4456        )
4457        .unwrap();
4458
4459        let mut rt = WorkflowRuntimeContext {
4460            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4461            def,
4462            runs_base_dir: paths.workflow_runs_dir(),
4463        };
4464        let mut hooks = FakeHooks;
4465        let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4466
4467        // Should have stopped at AwaitingWait (human gate open for reviewDecision).
4468        assert_eq!(
4469            result.reason,
4470            RunLoopStopReason::AwaitingWait,
4471            "expected AwaitingWait for open human gate"
4472        );
4473
4474        let events = rt.log.read_all().unwrap();
4475
4476        // Verify loop lifecycle events
4477        let loop_started = events.iter().find(|e| e.event_type == "loopStarted");
4478        assert!(loop_started.is_some(), "missing loopStarted");
4479        let iter_started = events
4480            .iter()
4481            .find(|e| e.event_type == "loopIterationStarted" && e.payload.get("iteration") == Some(&Value::Number(1.into())));
4482        assert!(iter_started.is_some(), "missing loopIterationStarted(1)");
4483
4484        // Verify activity IDs use the correct loop-scoped format
4485        let implement_work_id =
4486            format!("{}::loop::review-loop.1::work::implement", run_id);
4487        let review_work_id = format!("{}::loop::review-loop.1::work::review", run_id);
4488        let decision_gate_id =
4489            format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
4490
4491        let has_implement = events.iter().any(|e| {
4492            e.event_type == "attemptCreated"
4493                && e.payload.get("activityId").and_then(Value::as_str)
4494                    == Some(&implement_work_id)
4495        });
4496        let has_review = events.iter().any(|e| {
4497            e.event_type == "attemptCreated"
4498                && e.payload.get("activityId").and_then(Value::as_str)
4499                    == Some(&review_work_id)
4500        });
4501        let has_decision_gate = events.iter().any(|e| {
4502            e.event_type == "waitCreated"
4503                && e.payload.get("activityId").and_then(Value::as_str)
4504                    == Some(&decision_gate_id)
4505        });
4506
4507        assert!(has_implement, "missing implement work dispatch: {}", implement_work_id);
4508        assert!(has_review, "missing review work dispatch: {}", review_work_id);
4509        assert!(
4510            has_decision_gate,
4511            "missing reviewDecision gate wait: {}",
4512            decision_gate_id
4513        );
4514
4515        let _ = fs::remove_dir_all(&run_dir);
4516    }
4517
4518    #[tokio::test]
4519    async fn reject_decision_enters_next_iteration() {
4520        // Drive to the gate wait, then write a waitResolved(rejected).
4521        // run_loop should emit FinishLoopIteration(rejected) and StartLoopIteration(2).
4522        let run_dir = temp_run_dir("crl-reject");
4523        let _ = fs::remove_dir_all(&run_dir);
4524        fs::create_dir_all(run_dir.join("blobs")).unwrap();
4525        let paths = crate::BeamPaths::from_root(run_dir.clone());
4526        let run_id = "run-crl-reject";
4527        let def = code_review_loop_def();
4528        let workflow_json = serde_json::to_string(&def).unwrap();
4529        crate::bootstrap_workflow_run(
4530            &paths,
4531            crate::BootstrapWorkflowRunInput {
4532                run_id,
4533                workflow_json: &workflow_json,
4534                expected_workflow_id: Some("code-review-loop"),
4535                params: &BTreeMap::new(),
4536                initiator: "cli",
4537                chat_binding: Some(RunChatBinding {
4538                    chat_id: "chat-1".to_string(),
4539                    lark_app_id: "app-1".to_string(),
4540                }),
4541            },
4542        )
4543        .unwrap();
4544
4545        let decision_gate_id =
4546            format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
4547
4548        // First: drive to AwaitingWait.
4549        {
4550            let mut rt = WorkflowRuntimeContext {
4551                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4552                def: def.clone(),
4553                runs_base_dir: paths.workflow_runs_dir(),
4554            };
4555            let mut hooks = FakeHooks;
4556            let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4557            assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4558        }
4559
4560        // Write waitResolved (rejected).
4561        {
4562            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4563            let _ = log
4564                .append(EventDraft {
4565                    event_type: "waitResolved".to_string(),
4566                    actor: WorkflowActor::Human,
4567                    payload: serde_json::json!({
4568                        "activityId": &decision_gate_id,
4569                        "resolution": "rejected",
4570                        "by": "reviewer",
4571                        "comment": "needs work",
4572                    }),
4573                    timestamp: None,
4574                    payload_hash: None,
4575                })
4576                .unwrap();
4577        }
4578
4579        // Second: run_loop again — should handle the rejection.
4580        {
4581            let mut rt = WorkflowRuntimeContext {
4582                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4583                def,
4584                runs_base_dir: paths.workflow_runs_dir(),
4585            };
4586            let mut hooks = FakeHooks;
4587            let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4588
4589            let events = rt.log.read_all().unwrap();
4590
4591            // Verify FinishLoopIteration(rejected) for iteration 1.
4592            let iter1_finished = events.iter().any(|e| {
4593                e.event_type == "loopIterationFinished"
4594                    && e.payload.get("iteration").and_then(Value::as_u64) == Some(1)
4595                    && e.payload.get("resolution").and_then(Value::as_str) == Some("rejected")
4596            });
4597            assert!(iter1_finished, "expected loopIterationFinished with resolution=rejected for iteration 1");
4598
4599            // Verify StartLoopIteration(2).
4600            let iter2_started = events.iter().any(|e| {
4601                e.event_type == "loopIterationStarted"
4602                    && e.payload.get("iteration").and_then(Value::as_u64) == Some(2)
4603            });
4604            assert!(iter2_started, "expected loopIterationStarted with iteration=2");
4605
4606            // Verify iteration 2 dispatches work (body nodes run again).
4607            let implement_work_v2 =
4608                format!("{}::loop::review-loop.2::work::implement", run_id);
4609            let has_iter2_implement = events.iter().any(|e| {
4610                e.event_type == "attemptCreated"
4611                    && e.payload.get("activityId").and_then(Value::as_str)
4612                        == Some(&implement_work_v2)
4613            });
4614            assert!(
4615                has_iter2_implement,
4616                "expected iteration 2 to dispatch implement work: {}",
4617                implement_work_v2
4618            );
4619        }
4620
4621        let _ = fs::remove_dir_all(&run_dir);
4622    }
4623
4624    #[tokio::test]
4625    async fn approve_decision_finishes_loop_and_run_succeeds() {
4626        // Drive to gate wait, write waitResolved(approved), run_loop emits
4627        // FinishLoop(approved) and the run succeeds.
4628        let run_dir = temp_run_dir("crl-approve");
4629        let _ = fs::remove_dir_all(&run_dir);
4630        fs::create_dir_all(run_dir.join("blobs")).unwrap();
4631        let paths = crate::BeamPaths::from_root(run_dir.clone());
4632        let run_id = "run-crl-approve";
4633        let def = code_review_loop_def();
4634        let workflow_json = serde_json::to_string(&def).unwrap();
4635        crate::bootstrap_workflow_run(
4636            &paths,
4637            crate::BootstrapWorkflowRunInput {
4638                run_id,
4639                workflow_json: &workflow_json,
4640                expected_workflow_id: Some("code-review-loop"),
4641                params: &BTreeMap::new(),
4642                initiator: "cli",
4643                chat_binding: Some(RunChatBinding {
4644                    chat_id: "chat-1".to_string(),
4645                    lark_app_id: "app-1".to_string(),
4646                }),
4647            },
4648        )
4649        .unwrap();
4650
4651        let decision_gate_id =
4652            format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
4653
4654        // First: drive to AwaitingWait.
4655        {
4656            let mut rt = WorkflowRuntimeContext {
4657                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4658                def: def.clone(),
4659                runs_base_dir: paths.workflow_runs_dir(),
4660            };
4661            let mut hooks = FakeHooks;
4662            let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4663            assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4664        }
4665
4666        // Write waitResolved (approved).
4667        {
4668            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4669            let _ = log
4670                .append(EventDraft {
4671                    event_type: "waitResolved".to_string(),
4672                    actor: WorkflowActor::Human,
4673                    payload: serde_json::json!({
4674                        "activityId": &decision_gate_id,
4675                        "resolution": "approved",
4676                        "by": "reviewer",
4677                        "comment": "lgtm",
4678                    }),
4679                    timestamp: None,
4680                    payload_hash: None,
4681                })
4682                .unwrap();
4683        }
4684
4685        // Second: run_loop again — should approve and finish.
4686        {
4687            let mut rt = WorkflowRuntimeContext {
4688                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4689                def,
4690                runs_base_dir: paths.workflow_runs_dir(),
4691            };
4692            let mut hooks = FakeHooks;
4693            let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4694
4695            let events = rt.log.read_all().unwrap();
4696
4697            // Verify loopFinished with approved resolution.
4698            let loop_finished = events.iter().any(|e| {
4699                e.event_type == "loopFinished"
4700                    && e.payload.get("loopId").and_then(Value::as_str) == Some("review-loop")
4701                    && e.payload.get("resolution").and_then(Value::as_str) == Some("approved")
4702            });
4703            assert!(
4704                loop_finished,
4705                "expected loopFinished with resolution=approved"
4706            );
4707
4708            // Verify runSucceeded (since loop is the only top-level node).
4709            let run_succeeded = events.iter().any(|e| e.event_type == "runSucceeded");
4710            assert!(run_succeeded, "expected run to succeed after loop approval");
4711
4712            // Verify loop output is produced (from implement node).
4713            let snap = read_snapshot(&rt).await.unwrap();
4714            let loop_output_key = format!("{}::work::review-loop", run_id);
4715            assert!(
4716                snap.outputs.contains_key(&loop_output_key),
4717                "expected loop output under {}",
4718                loop_output_key
4719            );
4720        }
4721
4722        let _ = fs::remove_dir_all(&run_dir);
4723    }
4724
4725    /// Hook that fails every subagent call — used to test body failure.
4726    #[derive(Clone)]
4727    struct FailingBodyHooks;
4728
4729    #[async_trait]
4730    impl WorkflowExecutionHooks for FailingBodyHooks {
4731        async fn execute_subagent(
4732            &mut self,
4733            _ctx: WorkflowDispatchRun<'_>,
4734            _node: &SubagentNode,
4735            _resolved_prompt: String,
4736        ) -> Result<WorkflowDispatchOutcome> {
4737            Ok(WorkflowDispatchOutcome::Failed {
4738                error_code: "TestFailure".to_string(),
4739                error_class: "fatal".to_string(),
4740                error_message: "simulated body failure".to_string(),
4741                session: None,
4742            })
4743        }
4744
4745        async fn execute_host_executor(
4746            &mut self,
4747            _ctx: WorkflowDispatchRun<'_>,
4748            _node: &HostExecutorNode,
4749            _resolved_input: Value,
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
4760    #[tokio::test]
4761    async fn body_failure_causes_loop_failed() {
4762        // When a body node fails (subagent returns Failed), the loop should
4763        // immediately fail with FinishLoop(failed).
4764        let run_dir = temp_run_dir("crl-body-fail");
4765        let _ = fs::remove_dir_all(&run_dir);
4766        fs::create_dir_all(run_dir.join("blobs")).unwrap();
4767        let paths = crate::BeamPaths::from_root(run_dir.clone());
4768        let run_id = "run-crl-body-fail";
4769        let def = code_review_loop_def();
4770        let workflow_json = serde_json::to_string(&def).unwrap();
4771        crate::bootstrap_workflow_run(
4772            &paths,
4773            crate::BootstrapWorkflowRunInput {
4774                run_id,
4775                workflow_json: &workflow_json,
4776                expected_workflow_id: Some("code-review-loop"),
4777                params: &BTreeMap::new(),
4778                initiator: "cli",
4779                chat_binding: Some(RunChatBinding {
4780                    chat_id: "chat-1".to_string(),
4781                    lark_app_id: "app-1".to_string(),
4782                }),
4783            },
4784        )
4785        .unwrap();
4786
4787        let mut rt = WorkflowRuntimeContext {
4788            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4789            def,
4790            runs_base_dir: paths.workflow_runs_dir(),
4791        };
4792        let mut hooks = FailingBodyHooks;
4793        let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4794
4795        let events = rt.log.read_all().unwrap();
4796
4797        // Verify loopStarted and loopIterationStarted(1) were written.
4798        let loop_started = events.iter().any(|e| e.event_type == "loopStarted");
4799        assert!(loop_started, "expected loopStarted");
4800
4801        let iter_started = events.iter().any(|e| {
4802            e.event_type == "loopIterationStarted"
4803                && e.payload.get("iteration").and_then(Value::as_u64) == Some(1)
4804        });
4805        assert!(iter_started, "expected loopIterationStarted(1)");
4806
4807        // Body node (implement) should have failed.
4808        let implement_work_id =
4809            format!("{}::loop::review-loop.1::work::implement", run_id);
4810        let has_implement_fail = events.iter().any(|e| {
4811            e.event_type == "activityFailed"
4812                && e.payload.get("activityId").and_then(Value::as_str)
4813                    == Some(&implement_work_id)
4814        });
4815        assert!(
4816            has_implement_fail,
4817            "expected implement work to fail: {}",
4818            implement_work_id
4819        );
4820
4821        // Loop should have failed.
4822        let loop_failed = events.iter().any(|e| {
4823            e.event_type == "loopFinished"
4824                && e.payload.get("loopId").and_then(Value::as_str) == Some("review-loop")
4825                && e.payload.get("resolution").and_then(Value::as_str) == Some("failed")
4826        });
4827        assert!(
4828            loop_failed,
4829            "expected loopFinished with resolution=failed after body failure"
4830        );
4831
4832        // Run should have failed (loop failed → run failed).
4833        assert!(
4834            matches!(result.reason, RunLoopStopReason::Terminal),
4835            "expected terminal; reason={:?}",
4836            result.reason
4837        );
4838        let run_failed = events.iter().any(|e| e.event_type == "runFailed");
4839        assert!(run_failed, "expected run to fail after loop body failure");
4840
4841        let _ = fs::remove_dir_all(&run_dir);
4842    }
4843
4844    #[tokio::test]
4845    async fn max_iterations_reject_causes_loop_failed() {
4846        // Reject at the last allowed iteration (maxIterations=3, iteration 3).
4847        // Should produce FinishLoop(failed) with MaxIterationsReached.
4848        let run_dir = temp_run_dir("crl-maxiter");
4849        let _ = fs::remove_dir_all(&run_dir);
4850        fs::create_dir_all(run_dir.join("blobs")).unwrap();
4851        let paths = crate::BeamPaths::from_root(run_dir.clone());
4852        let run_id = "run-crl-maxiter";
4853        let def = code_review_loop_def();
4854        let workflow_json = serde_json::to_string(&def).unwrap();
4855        crate::bootstrap_workflow_run(
4856            &paths,
4857            crate::BootstrapWorkflowRunInput {
4858                run_id,
4859                workflow_json: &workflow_json,
4860                expected_workflow_id: Some("code-review-loop"),
4861                params: &BTreeMap::new(),
4862                initiator: "cli",
4863                chat_binding: Some(RunChatBinding {
4864                    chat_id: "chat-1".to_string(),
4865                    lark_app_id: "app-1".to_string(),
4866                }),
4867            },
4868        )
4869        .unwrap();
4870
4871        // Helper to drive to an open gate wait at a specific iteration.
4872        // We'll go through iterations 1→2→3, rejecting each time until the
4873        // final one causes failure.
4874
4875        let iter1_gate_id =
4876            format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
4877
4878        // Iteration 1: drive to gate, reject.
4879        {
4880            let mut rt = WorkflowRuntimeContext {
4881                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4882                def: def.clone(),
4883                runs_base_dir: paths.workflow_runs_dir(),
4884            };
4885            let mut hooks = FakeHooks;
4886            let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4887            assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4888        }
4889        // Reject iteration 1.
4890        {
4891            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4892            let _ = log
4893                .append(EventDraft {
4894                    event_type: "waitResolved".to_string(),
4895                    actor: WorkflowActor::Human,
4896                    payload: serde_json::json!({
4897                        "activityId": &iter1_gate_id,
4898                        "resolution": "rejected",
4899                        "by": "reviewer",
4900                        "comment": "redo",
4901                    }),
4902                    timestamp: None,
4903                    payload_hash: None,
4904                })
4905                .unwrap();
4906        }
4907        // Process rejection → iteration 2 should start.
4908        {
4909            let mut rt = WorkflowRuntimeContext {
4910                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4911                def: def.clone(),
4912                runs_base_dir: paths.workflow_runs_dir(),
4913            };
4914            let mut hooks = FakeHooks;
4915            let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4916
4917            let events = rt.log.read_all().unwrap();
4918            let iter2_started = events.iter().any(|e| {
4919                e.event_type == "loopIterationStarted"
4920                    && e.payload.get("iteration").and_then(Value::as_u64) == Some(2)
4921            });
4922            assert!(iter2_started, "expected iteration 2 started");
4923        }
4924
4925        // Iteration 2: drive to gate, reject.
4926        {
4927            let mut rt = WorkflowRuntimeContext {
4928                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4929                def: def.clone(),
4930                runs_base_dir: paths.workflow_runs_dir(),
4931            };
4932            let mut hooks = FakeHooks;
4933            let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4934            assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4935        }
4936        // Reject iteration 2.
4937        {
4938            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4939            let iter2_gate_id =
4940                format!("{}::loop::review-loop.2::gate::reviewDecision", run_id);
4941            let _ = log
4942                .append(EventDraft {
4943                    event_type: "waitResolved".to_string(),
4944                    actor: WorkflowActor::Human,
4945                    payload: serde_json::json!({
4946                        "activityId": &iter2_gate_id,
4947                        "resolution": "rejected",
4948                        "by": "reviewer",
4949                        "comment": "still no",
4950                    }),
4951                    timestamp: None,
4952                    payload_hash: None,
4953                })
4954                .unwrap();
4955        }
4956        // Process → iteration 3.
4957        {
4958            let mut rt = WorkflowRuntimeContext {
4959                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4960                def: def.clone(),
4961                runs_base_dir: paths.workflow_runs_dir(),
4962            };
4963            let mut hooks = FakeHooks;
4964            let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4965
4966            let events = rt.log.read_all().unwrap();
4967            let iter3_started = events.iter().any(|e| {
4968                e.event_type == "loopIterationStarted"
4969                    && e.payload.get("iteration").and_then(Value::as_u64) == Some(3)
4970            });
4971            assert!(iter3_started, "expected iteration 3 started");
4972        }
4973
4974        // Iteration 3: drive to gate, reject (max iterations hit).
4975        {
4976            let mut rt = WorkflowRuntimeContext {
4977                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4978                def: def.clone(),
4979                runs_base_dir: paths.workflow_runs_dir(),
4980            };
4981            let mut hooks = FakeHooks;
4982            let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4983            assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4984        }
4985        // Reject iteration 3 (final iteration) → loop should fail.
4986        {
4987            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4988            let iter3_gate_id =
4989                format!("{}::loop::review-loop.3::gate::reviewDecision", run_id);
4990            let _ = log
4991                .append(EventDraft {
4992                    event_type: "waitResolved".to_string(),
4993                    actor: WorkflowActor::Human,
4994                    payload: serde_json::json!({
4995                        "activityId": &iter3_gate_id,
4996                        "resolution": "rejected",
4997                        "by": "reviewer",
4998                        "comment": "still no",
4999                    }),
5000                    timestamp: None,
5001                    payload_hash: None,
5002                })
5003                .unwrap();
5004        }
5005        // Process → loop and run should fail.
5006        {
5007            let mut rt = WorkflowRuntimeContext {
5008                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5009                def,
5010                runs_base_dir: paths.workflow_runs_dir(),
5011            };
5012            let mut hooks = FakeHooks;
5013            let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5014
5015            let events = rt.log.read_all().unwrap();
5016
5017            // loopFinished with failed.
5018            let loop_failed = events.iter().any(|e| {
5019                e.event_type == "loopFinished"
5020                    && e.payload.get("loopId").and_then(Value::as_str) == Some("review-loop")
5021                    && e.payload.get("resolution").and_then(Value::as_str) == Some("failed")
5022                    && e.payload.get("errorCode").and_then(Value::as_str)
5023                        == Some("MaxIterationsReached")
5024            });
5025            assert!(
5026                loop_failed,
5027                "expected loopFinished with failed/MaxIterationsReached"
5028            );
5029
5030            assert!(
5031                matches!(result.reason, RunLoopStopReason::Terminal),
5032                "expected terminal; reason={:?}",
5033                result.reason
5034            );
5035            let run_failed = events.iter().any(|e| e.event_type == "runFailed");
5036            assert!(
5037                run_failed,
5038                "expected run to fail when max iterations reached"
5039            );
5040        }
5041
5042        let _ = fs::remove_dir_all(&run_dir);
5043    }
5044
5045    // ── Task 8.2: tests using the real code-review-loop.workflow.json ──
5046
5047    /// Like FakeHooks but returns a JSON object with common workflow output
5048    /// fields so that $ref bindings (implement.output.code,
5049    /// review.output.preview) work.
5050    #[derive(Clone)]
5051    struct RichFakeHooks;
5052
5053    #[async_trait]
5054    impl WorkflowExecutionHooks for RichFakeHooks {
5055        async fn execute_subagent(
5056            &mut self,
5057            _ctx: WorkflowDispatchRun<'_>,
5058            _node: &SubagentNode,
5059            _resolved_prompt: String,
5060        ) -> Result<WorkflowDispatchOutcome> {
5061            Ok(WorkflowDispatchOutcome::Succeeded {
5062                output: serde_json::json!({"code": "mock-code", "summary": "mock-summary", "preview": "looks good"}),
5063                session: None,
5064            })
5065        }
5066
5067        async fn execute_host_executor(
5068            &mut self,
5069            _ctx: WorkflowDispatchRun<'_>,
5070            _node: &HostExecutorNode,
5071            resolved_input: Value,
5072        ) -> Result<WorkflowDispatchOutcome> {
5073            Ok(WorkflowDispatchOutcome::Succeeded {
5074                output: resolved_input,
5075                session: None,
5076            })
5077        }
5078    }
5079
5080    /// Hook that records resolved subagent prompts for verification.
5081    #[derive(Clone)]
5082    struct RecordingHooks {
5083        prompts: Arc<Mutex<Vec<(String, String)>>>, // (node_id, resolved_prompt)
5084    }
5085
5086    #[async_trait]
5087    impl WorkflowExecutionHooks for RecordingHooks {
5088        async fn execute_subagent(
5089            &mut self,
5090            _ctx: WorkflowDispatchRun<'_>,
5091            _node: &SubagentNode,
5092            resolved_prompt: String,
5093        ) -> Result<WorkflowDispatchOutcome> {
5094            self.prompts.lock().await.push((
5095                _ctx.node_id.to_string(),
5096                resolved_prompt.clone(),
5097            ));
5098            // Produce a JSON object with common workflow output fields so
5099            // that $ref bindings (implement.output.code,
5100            // review.output.preview) resolve correctly.
5101            let out = serde_json::json!({"code": "mock-code", "summary": "mock-summary", "preview": "looks good"});
5102            Ok(WorkflowDispatchOutcome::Succeeded {
5103                output: out,
5104                session: None,
5105            })
5106        }
5107
5108        async fn execute_host_executor(
5109            &mut self,
5110            _ctx: WorkflowDispatchRun<'_>,
5111            _node: &HostExecutorNode,
5112            resolved_input: Value,
5113        ) -> Result<WorkflowDispatchOutcome> {
5114            Ok(WorkflowDispatchOutcome::Succeeded {
5115                output: resolved_input,
5116                session: None,
5117            })
5118        }
5119    }
5120
5121    #[tokio::test]
5122    async fn real_code_review_loop_iter1_reaches_awaiting_wait() {
5123        // Load the real workflow JSON, bootstrap with task param,
5124        // and verify iteration 1 reaches AwaitingWait without errors
5125        // from ${reviewDecision.previous.comment}.
5126        let raw = include_str!("../../../workflows/code-review-loop.workflow.json");
5127        let def = crate::parse_workflow_definition(raw).expect("parse real code-review-loop");
5128        let workflow_json = serde_json::to_string(&def).unwrap();
5129
5130        let run_dir = temp_run_dir("real-crl-iter1");
5131        let _ = fs::remove_dir_all(&run_dir);
5132        fs::create_dir_all(run_dir.join("blobs")).unwrap();
5133        let paths = crate::BeamPaths::from_root(run_dir.clone());
5134        let run_id = "run-real-crl-1";
5135        let params = BTreeMap::from([(String::from("task"), String::from("add CLI echo command"))]);
5136        crate::bootstrap_workflow_run(
5137            &paths,
5138            crate::BootstrapWorkflowRunInput {
5139                run_id,
5140                workflow_json: &workflow_json,
5141                expected_workflow_id: Some("code-review-loop"),
5142                params: &params,
5143                initiator: "cli",
5144                chat_binding: Some(RunChatBinding {
5145                    chat_id: "chat-1".to_string(),
5146                    lark_app_id: "app-1".to_string(),
5147                }),
5148            },
5149        )
5150        .unwrap();
5151
5152        let prompts = Arc::new(Mutex::new(Vec::new()));
5153        let mut hooks = RecordingHooks {
5154            prompts: prompts.clone(),
5155        };
5156
5157        let mut rt = WorkflowRuntimeContext {
5158            log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5159            def,
5160            runs_base_dir: paths.workflow_runs_dir(),
5161        };
5162        let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5163
5164        assert_eq!(
5165            result.reason,
5166            RunLoopStopReason::AwaitingWait,
5167            "expected AwaitingWait for open human gate"
5168        );
5169
5170        // Verify implement's resolved prompt contains the task and does NOT
5171        // error on ${reviewDecision.previous.comment} (iteration 1 → empty).
5172        let recorded = prompts.lock().await;
5173        let implement_prompt = recorded
5174            .iter()
5175            .find(|(node_id, _)| node_id == "implement")
5176            .map(|(_, p)| p.clone())
5177            .expect("implement must have been dispatched");
5178        eprintln!("implement prompt iter1: {implement_prompt}");
5179        assert!(
5180            implement_prompt.contains("add CLI echo command"),
5181            "implement prompt should contain the task param"
5182        );
5183        // In iteration 1, .previous.comment resolves to empty string.
5184        assert!(
5185            !implement_prompt.contains("ERROR")
5186                && !implement_prompt.contains("no previous iteration"),
5187            "implement prompt should not contain binding errors: {implement_prompt}"
5188        );
5189
5190        // Verify loop-scoped activity IDs exist
5191        let events = rt.log.read_all().unwrap();
5192        let decision_gate_id =
5193            format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
5194        let has_gate_wait = events.iter().any(|e| {
5195            e.event_type == "waitCreated"
5196                && e.payload.get("activityId").and_then(Value::as_str)
5197                    == Some(&decision_gate_id)
5198        });
5199        assert!(
5200            has_gate_wait,
5201            "expected gate wait for {}",
5202            decision_gate_id
5203        );
5204
5205        let _ = fs::remove_dir_all(&run_dir);
5206    }
5207
5208    #[tokio::test]
5209    async fn real_code_review_loop_reject_enters_iter2_with_comment() {
5210        // Iteration 1: drive to gate, reject with comment.
5211        // Iteration 2: verify implement's resolved prompt contains the
5212        // reject comment from the previous iteration.
5213        let raw = include_str!("../../../workflows/code-review-loop.workflow.json");
5214        let def = crate::parse_workflow_definition(raw).expect("parse real code-review-loop");
5215        let workflow_json = serde_json::to_string(&def).unwrap();
5216
5217        let run_dir = temp_run_dir("real-crl-reject");
5218        let _ = fs::remove_dir_all(&run_dir);
5219        fs::create_dir_all(run_dir.join("blobs")).unwrap();
5220        let paths = crate::BeamPaths::from_root(run_dir.clone());
5221        let run_id = "run-real-crl-rej";
5222        let params = BTreeMap::from([(String::from("task"), String::from("add test"))]);
5223        crate::bootstrap_workflow_run(
5224            &paths,
5225            crate::BootstrapWorkflowRunInput {
5226                run_id,
5227                workflow_json: &workflow_json,
5228                expected_workflow_id: Some("code-review-loop"),
5229                params: &params,
5230                initiator: "cli",
5231                chat_binding: Some(RunChatBinding {
5232                    chat_id: "chat-1".to_string(),
5233                    lark_app_id: "app-1".to_string(),
5234                }),
5235            },
5236        )
5237        .unwrap();
5238
5239        let prompts = Arc::new(Mutex::new(Vec::new()));
5240
5241        // Iteration 1: drive to AwaitingWait.
5242        {
5243            let mut hooks = RecordingHooks {
5244                prompts: prompts.clone(),
5245            };
5246            let mut rt = WorkflowRuntimeContext {
5247                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5248                def: def.clone(),
5249                runs_base_dir: paths.workflow_runs_dir(),
5250            };
5251            let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5252            assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
5253        }
5254
5255        // Reject iteration 1 with a comment.
5256        let decision_gate_id =
5257            format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
5258        {
5259            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
5260            let _ = log
5261                .append(EventDraft {
5262                    event_type: "waitResolved".to_string(),
5263                    actor: WorkflowActor::Human,
5264                    payload: serde_json::json!({
5265                        "activityId": &decision_gate_id,
5266                        "resolution": "rejected",
5267                        "by": "reviewer",
5268                        "comment": "needs more tests",
5269                    }),
5270                    timestamp: None,
5271                    payload_hash: None,
5272                })
5273                .unwrap();
5274        }
5275
5276        // Process rejection → iteration 2 should start.
5277        {
5278            let mut hooks = RecordingHooks {
5279                prompts: prompts.clone(),
5280            };
5281            let mut rt = WorkflowRuntimeContext {
5282                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5283                def: def.clone(),
5284                runs_base_dir: paths.workflow_runs_dir(),
5285            };
5286            let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5287
5288            // Verify loopIterationFinished metadata
5289            let events = rt.log.read_all().unwrap();
5290            let iter1_finished = events.iter().find(|e| {
5291                e.event_type == "loopIterationFinished"
5292                    && e.payload.get("iteration").and_then(Value::as_u64) == Some(1)
5293            });
5294            assert!(iter1_finished.is_some(), "expected loopIterationFinished for iteration 1");
5295            let payload = &iter1_finished.unwrap().payload;
5296            assert_eq!(
5297                payload.get("resolution").and_then(Value::as_str),
5298                Some("rejected"),
5299                "resolution should be rejected"
5300            );
5301            assert_eq!(
5302                payload.get("by").and_then(Value::as_str),
5303                Some("reviewer"),
5304                "by should be reviewer"
5305            );
5306            assert_eq!(
5307                payload.get("comment").and_then(Value::as_str),
5308                Some("needs more tests"),
5309                "comment should be preserved"
5310            );
5311            assert_eq!(
5312                payload.get("decisionActivityId").and_then(Value::as_str),
5313                Some(decision_gate_id.as_str()),
5314                "decisionActivityId should be the gate id"
5315            );
5316
5317            let iter2_started = events.iter().any(|e| {
5318                e.event_type == "loopIterationStarted"
5319                    && e.payload.get("iteration").and_then(Value::as_u64) == Some(2)
5320            });
5321            assert!(iter2_started, "expected iteration 2 started");
5322        }
5323
5324        // Now verify iteration 2 implement prompt contains the reject comment.
5325        // The runtime already dispatched implement in iteration 2 during the
5326        // above run_loop call.
5327        let recorded = prompts.lock().await;
5328        let iter2_implement = recorded
5329            .iter()
5330            .filter(|(node_id, _)| node_id == "implement")
5331            .last()
5332            .map(|(_, p)| p.clone())
5333            .expect("implement iter2 must have been dispatched");
5334        eprintln!("implement prompt iter2: {iter2_implement}");
5335        assert!(
5336            iter2_implement.contains("needs more tests"),
5337            "iter2 implement prompt should contain reject comment 'needs more tests': {iter2_implement}"
5338        );
5339
5340        let _ = fs::remove_dir_all(&run_dir);
5341    }
5342
5343    #[tokio::test]
5344    async fn real_code_review_loop_approve_succeeds() {
5345        // Load real workflow, drive to gate, approve, verify loop/run succeeded.
5346        let raw = include_str!("../../../workflows/code-review-loop.workflow.json");
5347        let def = crate::parse_workflow_definition(raw).expect("parse real code-review-loop");
5348        let workflow_json = serde_json::to_string(&def).unwrap();
5349
5350        let run_dir = temp_run_dir("real-crl-approve");
5351        let _ = fs::remove_dir_all(&run_dir);
5352        fs::create_dir_all(run_dir.join("blobs")).unwrap();
5353        let paths = crate::BeamPaths::from_root(run_dir.clone());
5354        let run_id = "run-real-crl-app";
5355        let params = BTreeMap::from([(String::from("task"), String::from("add feature"))]);
5356        crate::bootstrap_workflow_run(
5357            &paths,
5358            crate::BootstrapWorkflowRunInput {
5359                run_id,
5360                workflow_json: &workflow_json,
5361                expected_workflow_id: Some("code-review-loop"),
5362                params: &params,
5363                initiator: "cli",
5364                chat_binding: Some(RunChatBinding {
5365                    chat_id: "chat-1".to_string(),
5366                    lark_app_id: "app-1".to_string(),
5367                }),
5368            },
5369        )
5370        .unwrap();
5371
5372        // Iteration 1: drive to AwaitingWait.
5373        {
5374            let mut hooks = RichFakeHooks;
5375            let mut rt = WorkflowRuntimeContext {
5376                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5377                def: def.clone(),
5378                runs_base_dir: paths.workflow_runs_dir(),
5379            };
5380            let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5381            assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
5382        }
5383
5384        // Approve.
5385        let decision_gate_id =
5386            format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
5387        {
5388            let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
5389            let _ = log
5390                .append(EventDraft {
5391                    event_type: "waitResolved".to_string(),
5392                    actor: WorkflowActor::Human,
5393                    payload: serde_json::json!({
5394                        "activityId": &decision_gate_id,
5395                        "resolution": "approved",
5396                        "by": "approver",
5397                        "comment": "lgtm",
5398                    }),
5399                    timestamp: None,
5400                    payload_hash: None,
5401                })
5402                .unwrap();
5403        }
5404
5405        // Process approval → loop and run succeed.
5406        {
5407            let mut hooks = RichFakeHooks;
5408            let mut rt = WorkflowRuntimeContext {
5409                log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5410                def,
5411                runs_base_dir: paths.workflow_runs_dir(),
5412            };
5413            let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5414
5415            let events = rt.log.read_all().unwrap();
5416
5417            let loop_finished = events.iter().find(|e| {
5418                e.event_type == "loopFinished"
5419                    && e.payload.get("loopId").and_then(Value::as_str) == Some("review-loop")
5420            });
5421            assert!(loop_finished.is_some(), "expected loopFinished");
5422            let payload = &loop_finished.unwrap().payload;
5423            assert_eq!(
5424                payload.get("resolution").and_then(Value::as_str),
5425                Some("approved")
5426            );
5427
5428            let run_succeeded = events.iter().any(|e| e.event_type == "runSucceeded");
5429            assert!(run_succeeded, "expected run to succeed after loop approval");
5430
5431            assert!(matches!(result.reason, RunLoopStopReason::Terminal));
5432
5433            // Verify loop output exists.
5434            let snap = read_snapshot(&rt).await.unwrap();
5435            let loop_output_key = format!("{}::work::review-loop", run_id);
5436            assert!(
5437                snap.outputs.contains_key(&loop_output_key),
5438                "expected loop output"
5439            );
5440        }
5441
5442        let _ = fs::remove_dir_all(&run_dir);
5443    }
5444}