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#[derive(Debug, Clone)]
77pub struct HostExecutorPrepareResult {
78 pub parsed_input: Value,
80 pub canonical_input: Value,
83 pub provider: String,
85 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: Value,
104 ) -> Result<WorkflowDispatchOutcome>;
105
106 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct RecoveryResult {
189 pub had_progress: bool,
191 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 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 continue;
371 }
372 }
377
378 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 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 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 let prepared = hooks
730 .prepare_host_executor(&executor.executor, &resolved_input)
731 .context("prepare_host_executor failed")?;
732
733 let _ = write_effect_input_sidecar(
735 &rt.log,
736 activity_id,
737 &attempt_id,
738 &prepared.canonical_input,
739 )
740 .await?;
741
742 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
1225pub 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
1255pub 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
1278async 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 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 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 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: ¶ms,
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: ¶ms,
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: ¶ms,
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: ¶ms,
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: ¶ms,
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 #[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 let work_activity_id = format!("{}::work::a", run_id);
2329 let work_attempt_id = format!("{}::work::a::att-1", run_id);
2330
2331 {
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 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 #[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 let work_activity_id = format!("{}::work::a", run_id);
2521 let work_attempt_id = format!("{}::work::a::att-1", run_id);
2522
2523 {
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 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 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 let work_activity_id = format!("{}::work::a", run_id);
2644 let work_attempt_id = format!("{}::work::a::att-1", run_id);
2645
2646 {
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 let mut hooks = FakeHooks;
2718 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
2719
2720 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 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 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 #[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 }
2810
2811 let mut hooks = DefaultingHooks;
2812
2813 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 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 #[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 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 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 #[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 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 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 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 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 #[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 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 {
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 {
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 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 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 #[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 {
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 {
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 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 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 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(
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 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 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 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 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 {
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 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 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 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 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 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 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 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: ¶ms,
4265 initiator: "cli",
4266 chat_binding: None,
4267 },
4268 )
4269 .unwrap();
4270
4271 {
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 {
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 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 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 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 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 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 {
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 {
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 {
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 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 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 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 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 {
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 {
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 {
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 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 let run_succeeded = events.iter().any(|e| e.event_type == "runSucceeded");
4734 assert!(run_succeeded, "expected run to succeed after loop approval");
4735
4736 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 #[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 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 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 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 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 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 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 let iter1_gate_id = format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
4898
4899 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 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 #[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 #[derive(Clone)]
5101 struct RecordingHooks {
5102 prompts: Arc<Mutex<Vec<(String, String)>>>, }
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 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 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: ¶ms,
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 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 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 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 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: ¶ms,
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 {
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 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 {
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 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 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 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: ¶ms,
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 {
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 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 {
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 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}