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