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