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