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::{
1410 DecisionNode, HumanGate, LoopNode, LoopOutputProjection, LoopTerminate,
1411 };
1412 use crate::workflow_snapshot::NodeStatus;
1413 use std::collections::{BTreeMap, HashMap};
1414 use std::sync::Arc;
1415 use tokio::sync::Mutex;
1416
1417 #[derive(Clone)]
1418 struct FakeHooks;
1419
1420 #[async_trait]
1421 impl WorkflowExecutionHooks for FakeHooks {
1422 async fn execute_subagent(
1423 &mut self,
1424 _ctx: WorkflowDispatchRun<'_>,
1425 _node: &SubagentNode,
1426 resolved_prompt: String,
1427 ) -> Result<WorkflowDispatchOutcome> {
1428 Ok(WorkflowDispatchOutcome::Succeeded {
1429 output: Value::String(resolved_prompt),
1430 session: None,
1431 })
1432 }
1433
1434 async fn execute_host_executor(
1435 &mut self,
1436 _ctx: WorkflowDispatchRun<'_>,
1437 _node: &HostExecutorNode,
1438 resolved_input: Value,
1439 ) -> Result<WorkflowDispatchOutcome> {
1440 Ok(WorkflowDispatchOutcome::Succeeded {
1441 output: resolved_input,
1442 session: None,
1443 })
1444 }
1445 }
1446
1447 fn temp_run_dir(label: &str) -> PathBuf {
1448 std::env::temp_dir().join(format!(
1449 "beam-workflow-runtime-{label}-{}-{}",
1450 std::process::id(),
1451 std::time::SystemTime::now()
1452 .duration_since(std::time::UNIX_EPOCH)
1453 .unwrap_or_default()
1454 .as_nanos()
1455 ))
1456 }
1457
1458 fn workflow_def() -> WorkflowDefinition {
1459 WorkflowDefinition {
1460 workflow_id: "flow-a".to_string(),
1461 version: 1,
1462 params: None,
1463 defaults: None,
1464 nodes: BTreeMap::from([(
1465 "a".to_string(),
1466 WorkflowNode::Subagent(SubagentNode {
1467 base: NodeBase {
1468 description: None,
1469 depends: None,
1470 human_gate: None,
1471 retry_policy: None,
1472 timeout_ms: None,
1473 max_output_bytes: None,
1474 output_schema: None,
1475 unsafe_allow_ungated: None,
1476 },
1477 bot: "bot-a".to_string(),
1478 prompt: Value::String("hello ${params.name}".to_string()),
1479 working_dir: None,
1480 model_overrides: None,
1481 tool_policy: None,
1482 }),
1483 )]),
1484 }
1485 }
1486
1487 #[tokio::test]
1488 async fn run_tick_dispatches_simple_workflow() {
1489 let run_dir = temp_run_dir("tick");
1490 let _ = fs::remove_dir_all(&run_dir);
1491 fs::create_dir_all(run_dir.join("blobs")).unwrap();
1492 fs::write(run_dir.join("params.json"), r#"{"name":"beam"}"#).unwrap();
1493
1494 let paths = crate::BeamPaths::from_root(run_dir.clone());
1495 let params: BTreeMap<String, Value> =
1496 BTreeMap::from([(String::from("name"), Value::String("beam".to_string()))]);
1497 let run_id = "run-1";
1498 let bootstrap = crate::bootstrap_workflow_run(
1499 &paths,
1500 crate::BootstrapWorkflowRunInput {
1501 run_id,
1502 workflow_json: r#"{"workflowId":"flow-a","version":1,"params":{"name":{"type":"string"}},"nodes":{"a":{"type":"subagent","bot":"bot-a","prompt":"hello ${params.name}"}}}"#,
1503 expected_workflow_id: Some("flow-a"),
1504 params: ¶ms,
1505 initiator: "cli",
1506 chat_binding: Some(RunChatBinding {
1507 chat_id: "chat-1".to_string(),
1508 lark_app_id: "app-1".to_string(),
1509 }),
1510 },
1511 )
1512 .unwrap();
1513 let mut rt = WorkflowRuntimeContext {
1514 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
1515 def: workflow_def(),
1516 runs_base_dir: paths.workflow_runs_dir(),
1517 };
1518 let mut hooks = FakeHooks;
1519 let tick = run_tick(&mut rt, &mut hooks, 1).await.unwrap();
1520 assert!(tick.actions > 0);
1521 assert_eq!(tick.snapshot.run.workflow_id.as_deref(), Some("flow-a"));
1522 assert!(matches!(
1523 tick.snapshot
1524 .nodes
1525 .iter()
1526 .find(|node| node.node_id == "a")
1527 .map(|node| node.status),
1528 Some(NodeStatus::Succeeded | NodeStatus::Running | NodeStatus::Triggered)
1529 ));
1530 let _ = fs::remove_dir_all(&run_dir);
1531 let _ = bootstrap;
1532 }
1533
1534 #[tokio::test]
1535 async fn run_tick_honors_max_concurrency_cap() {
1536 let run_dir = temp_run_dir("cap");
1537 let _ = fs::remove_dir_all(&run_dir);
1538 fs::create_dir_all(run_dir.join("blobs")).unwrap();
1539 let paths = crate::BeamPaths::from_root(run_dir.clone());
1540 let params: BTreeMap<String, Value> = BTreeMap::new();
1541 let run_id = "run-cap";
1542 crate::bootstrap_workflow_run(
1543 &paths,
1544 crate::BootstrapWorkflowRunInput {
1545 run_id,
1546 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"}}}"#,
1547 expected_workflow_id: Some("flow-cap"),
1548 params: ¶ms,
1549 initiator: "cli",
1550 chat_binding: Some(RunChatBinding {
1551 chat_id: "chat-1".to_string(),
1552 lark_app_id: "app-1".to_string(),
1553 }),
1554 },
1555 )
1556 .unwrap();
1557 let mut rt = WorkflowRuntimeContext {
1558 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
1559 def: WorkflowDefinition {
1560 workflow_id: "flow-cap".to_string(),
1561 version: 1,
1562 params: None,
1563 defaults: None,
1564 nodes: BTreeMap::from([
1565 (
1566 "a".to_string(),
1567 WorkflowNode::Subagent(SubagentNode {
1568 base: NodeBase {
1569 description: None,
1570 depends: None,
1571 human_gate: None,
1572 retry_policy: None,
1573 timeout_ms: None,
1574 max_output_bytes: None,
1575 output_schema: None,
1576 unsafe_allow_ungated: None,
1577 },
1578 bot: "bot-a".to_string(),
1579 prompt: Value::String("one".to_string()),
1580 working_dir: None,
1581 model_overrides: None,
1582 tool_policy: None,
1583 }),
1584 ),
1585 (
1586 "b".to_string(),
1587 WorkflowNode::Subagent(SubagentNode {
1588 base: NodeBase {
1589 description: None,
1590 depends: None,
1591 human_gate: None,
1592 retry_policy: None,
1593 timeout_ms: None,
1594 max_output_bytes: None,
1595 output_schema: None,
1596 unsafe_allow_ungated: None,
1597 },
1598 bot: "bot-b".to_string(),
1599 prompt: Value::String("two".to_string()),
1600 working_dir: None,
1601 model_overrides: None,
1602 tool_policy: None,
1603 }),
1604 ),
1605 ]),
1606 },
1607 runs_base_dir: paths.workflow_runs_dir(),
1608 };
1609 let mut hooks = FakeHooks;
1610 let tick = run_tick(&mut rt, &mut hooks, 1).await.unwrap();
1611 assert_eq!(tick.actions, 1);
1612 let snapshot = tick.snapshot;
1613 let attempted: Vec<_> = snapshot
1614 .activities
1615 .iter()
1616 .map(|a| a.activity_id.as_str())
1617 .collect();
1618 assert_eq!(attempted.len(), 1);
1619 let _ = fs::remove_dir_all(&run_dir);
1620 }
1621
1622 #[tokio::test]
1623 async fn run_tick_dispatches_multiple_actions_concurrently() {
1624 let run_dir = temp_run_dir("concurrent");
1625 let _ = fs::remove_dir_all(&run_dir);
1626 fs::create_dir_all(run_dir.join("blobs")).unwrap();
1627 let paths = crate::BeamPaths::from_root(run_dir.clone());
1628 let params: BTreeMap<String, Value> = BTreeMap::new();
1629 let run_id = "run-concurrent";
1630 crate::bootstrap_workflow_run(
1631 &paths,
1632 crate::BootstrapWorkflowRunInput {
1633 run_id,
1634 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"}}}"#,
1635 expected_workflow_id: Some("flow-concurrent"),
1636 params: ¶ms,
1637 initiator: "cli",
1638 chat_binding: Some(RunChatBinding {
1639 chat_id: "chat-1".to_string(),
1640 lark_app_id: "app-1".to_string(),
1641 }),
1642 },
1643 )
1644 .unwrap();
1645 let mut rt = WorkflowRuntimeContext {
1646 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
1647 def: WorkflowDefinition {
1648 workflow_id: "flow-concurrent".to_string(),
1649 version: 1,
1650 params: None,
1651 defaults: None,
1652 nodes: BTreeMap::from([
1653 (
1654 "a".to_string(),
1655 WorkflowNode::Subagent(SubagentNode {
1656 base: NodeBase {
1657 description: None,
1658 depends: None,
1659 human_gate: None,
1660 retry_policy: None,
1661 timeout_ms: None,
1662 max_output_bytes: None,
1663 output_schema: None,
1664 unsafe_allow_ungated: None,
1665 },
1666 bot: "bot-a".to_string(),
1667 prompt: Value::String("one".to_string()),
1668 working_dir: None,
1669 model_overrides: None,
1670 tool_policy: None,
1671 }),
1672 ),
1673 (
1674 "b".to_string(),
1675 WorkflowNode::Subagent(SubagentNode {
1676 base: NodeBase {
1677 description: None,
1678 depends: None,
1679 human_gate: None,
1680 retry_policy: None,
1681 timeout_ms: None,
1682 max_output_bytes: None,
1683 output_schema: None,
1684 unsafe_allow_ungated: None,
1685 },
1686 bot: "bot-b".to_string(),
1687 prompt: Value::String("two".to_string()),
1688 working_dir: None,
1689 model_overrides: None,
1690 tool_policy: None,
1691 }),
1692 ),
1693 ]),
1694 },
1695 runs_base_dir: paths.workflow_runs_dir(),
1696 };
1697
1698 #[derive(Clone)]
1699 struct SleepyHooks;
1700
1701 #[async_trait]
1702 impl WorkflowExecutionHooks for SleepyHooks {
1703 async fn execute_subagent(
1704 &mut self,
1705 _ctx: WorkflowDispatchRun<'_>,
1706 _node: &SubagentNode,
1707 resolved_prompt: String,
1708 ) -> Result<WorkflowDispatchOutcome> {
1709 tokio::time::sleep(std::time::Duration::from_millis(120)).await;
1710 Ok(WorkflowDispatchOutcome::Succeeded {
1711 output: Value::String(resolved_prompt),
1712 session: None,
1713 })
1714 }
1715
1716 async fn execute_host_executor(
1717 &mut self,
1718 _ctx: WorkflowDispatchRun<'_>,
1719 _node: &HostExecutorNode,
1720 resolved_input: Value,
1721 ) -> Result<WorkflowDispatchOutcome> {
1722 Ok(WorkflowDispatchOutcome::Succeeded {
1723 output: resolved_input,
1724 session: None,
1725 })
1726 }
1727 }
1728
1729 let mut hooks = SleepyHooks;
1730 let started = std::time::Instant::now();
1731 let tick = run_tick(&mut rt, &mut hooks, 2).await.unwrap();
1732 let elapsed = started.elapsed();
1733 assert_eq!(tick.actions, 2);
1734 assert!(
1735 elapsed < std::time::Duration::from_millis(220),
1736 "run_tick took {:?}, expected concurrent execution under 220ms",
1737 elapsed
1738 );
1739 let _ = fs::remove_dir_all(&run_dir);
1740 }
1741
1742 #[tokio::test]
1743 async fn run_tick_serializes_actions_for_the_same_bot() {
1744 let run_dir = temp_run_dir("same-bot");
1745 let _ = fs::remove_dir_all(&run_dir);
1746 fs::create_dir_all(run_dir.join("blobs")).unwrap();
1747 let paths = crate::BeamPaths::from_root(run_dir.clone());
1748 let params: BTreeMap<String, Value> = BTreeMap::new();
1749 let run_id = "run-same-bot";
1750 crate::bootstrap_workflow_run(
1751 &paths,
1752 crate::BootstrapWorkflowRunInput {
1753 run_id,
1754 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"}}}"#,
1755 expected_workflow_id: Some("flow-same-bot"),
1756 params: ¶ms,
1757 initiator: "cli",
1758 chat_binding: Some(RunChatBinding {
1759 chat_id: "chat-1".to_string(),
1760 lark_app_id: "app-1".to_string(),
1761 }),
1762 },
1763 )
1764 .unwrap();
1765 let mut rt = WorkflowRuntimeContext {
1766 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
1767 def: WorkflowDefinition {
1768 workflow_id: "flow-same-bot".to_string(),
1769 version: 1,
1770 params: None,
1771 defaults: None,
1772 nodes: BTreeMap::from([
1773 (
1774 "a".to_string(),
1775 WorkflowNode::Subagent(SubagentNode {
1776 base: NodeBase {
1777 description: None,
1778 depends: None,
1779 human_gate: None,
1780 retry_policy: None,
1781 timeout_ms: None,
1782 max_output_bytes: None,
1783 output_schema: None,
1784 unsafe_allow_ungated: None,
1785 },
1786 bot: "bot-shared".to_string(),
1787 prompt: Value::String("one".to_string()),
1788 working_dir: None,
1789 model_overrides: None,
1790 tool_policy: None,
1791 }),
1792 ),
1793 (
1794 "b".to_string(),
1795 WorkflowNode::Subagent(SubagentNode {
1796 base: NodeBase {
1797 description: None,
1798 depends: None,
1799 human_gate: None,
1800 retry_policy: None,
1801 timeout_ms: None,
1802 max_output_bytes: None,
1803 output_schema: None,
1804 unsafe_allow_ungated: None,
1805 },
1806 bot: "bot-shared".to_string(),
1807 prompt: Value::String("two".to_string()),
1808 working_dir: None,
1809 model_overrides: None,
1810 tool_policy: None,
1811 }),
1812 ),
1813 (
1814 "c".to_string(),
1815 WorkflowNode::Subagent(SubagentNode {
1816 base: NodeBase {
1817 description: None,
1818 depends: None,
1819 human_gate: None,
1820 retry_policy: None,
1821 timeout_ms: None,
1822 max_output_bytes: None,
1823 output_schema: None,
1824 unsafe_allow_ungated: None,
1825 },
1826 bot: "bot-other".to_string(),
1827 prompt: Value::String("three".to_string()),
1828 working_dir: None,
1829 model_overrides: None,
1830 tool_policy: None,
1831 }),
1832 ),
1833 ]),
1834 },
1835 runs_base_dir: paths.workflow_runs_dir(),
1836 };
1837
1838 #[derive(Clone)]
1839 struct SerializingHooks {
1840 started: Arc<Mutex<Vec<String>>>,
1841 active_by_bot: Arc<Mutex<HashMap<String, usize>>>,
1842 max_active_by_bot: Arc<Mutex<HashMap<String, usize>>>,
1843 }
1844
1845 #[async_trait]
1846 impl WorkflowExecutionHooks for SerializingHooks {
1847 async fn execute_subagent(
1848 &mut self,
1849 _ctx: WorkflowDispatchRun<'_>,
1850 node: &SubagentNode,
1851 resolved_prompt: String,
1852 ) -> Result<WorkflowDispatchOutcome> {
1853 let bot = node.bot.clone();
1854 {
1855 let mut active = self.active_by_bot.lock().await;
1856 let entry = active.entry(bot.clone()).or_insert(0);
1857 *entry += 1;
1858 let mut max_active = self.max_active_by_bot.lock().await;
1859 let max_entry = max_active.entry(bot.clone()).or_insert(0);
1860 if *entry > *max_entry {
1861 *max_entry = *entry;
1862 }
1863 assert!(
1864 *entry <= 1,
1865 "bot {} ran concurrently with itself: {}",
1866 bot,
1867 *entry
1868 );
1869 }
1870 self.started.lock().await.push(resolved_prompt.clone());
1871 tokio::time::sleep(std::time::Duration::from_millis(120)).await;
1872 {
1873 let mut active = self.active_by_bot.lock().await;
1874 if let Some(entry) = active.get_mut(&bot) {
1875 *entry = entry.saturating_sub(1);
1876 }
1877 }
1878 Ok(WorkflowDispatchOutcome::Succeeded {
1879 output: Value::String(resolved_prompt),
1880 session: None,
1881 })
1882 }
1883
1884 async fn execute_host_executor(
1885 &mut self,
1886 _ctx: WorkflowDispatchRun<'_>,
1887 _node: &HostExecutorNode,
1888 resolved_input: Value,
1889 ) -> Result<WorkflowDispatchOutcome> {
1890 Ok(WorkflowDispatchOutcome::Succeeded {
1891 output: resolved_input,
1892 session: None,
1893 })
1894 }
1895 }
1896
1897 let started = Arc::new(Mutex::new(Vec::new()));
1898 let active_by_bot = Arc::new(Mutex::new(HashMap::new()));
1899 let max_active_by_bot = Arc::new(Mutex::new(HashMap::new()));
1900 let mut hooks = SerializingHooks {
1901 started: started.clone(),
1902 active_by_bot: active_by_bot.clone(),
1903 max_active_by_bot: max_active_by_bot.clone(),
1904 };
1905
1906 let started_at = std::time::Instant::now();
1907 let tick = run_tick(&mut rt, &mut hooks, 2).await.unwrap();
1908 let elapsed = started_at.elapsed();
1909
1910 assert_eq!(tick.actions, 2);
1911 let started = started.lock().await.clone();
1912 assert_eq!(started.len(), 2);
1913 assert!(started.contains(&"one".to_string()));
1914 assert!(started.contains(&"three".to_string()));
1915 assert!(!started.contains(&"two".to_string()));
1916 let max_active_by_bot = max_active_by_bot.lock().await;
1917 assert_eq!(max_active_by_bot.get("bot-shared"), Some(&1));
1918 assert_eq!(max_active_by_bot.get("bot-other"), Some(&1));
1919 assert!(
1920 elapsed < std::time::Duration::from_millis(220),
1921 "run_tick took {:?}, expected two distinct bots to run concurrently",
1922 elapsed
1923 );
1924 let _ = fs::remove_dir_all(&run_dir);
1925 }
1926
1927 #[derive(Clone)]
1928 struct CancellingHooks {
1929 run_id: String,
1930 runs_base_dir: PathBuf,
1931 calls: Arc<Mutex<usize>>,
1932 completed: Arc<Mutex<usize>>,
1933 }
1934
1935 #[async_trait]
1936 impl WorkflowExecutionHooks for CancellingHooks {
1937 async fn execute_subagent(
1938 &mut self,
1939 _ctx: WorkflowDispatchRun<'_>,
1940 _node: &SubagentNode,
1941 resolved_prompt: String,
1942 ) -> Result<WorkflowDispatchOutcome> {
1943 let mut calls = self.calls.lock().await;
1944 *calls += 1;
1945 if *calls == 1 {
1946 let run_id = self.run_id.clone();
1947 let runs_base_dir = self.runs_base_dir.clone();
1948 tokio::spawn(async move {
1949 tokio::time::sleep(std::time::Duration::from_millis(1)).await;
1950 let mut log = EventLog::new(run_id.clone(), runs_base_dir).unwrap();
1951 let _ = log.append(EventDraft {
1952 event_type: "cancelRequested".to_string(),
1953 actor: WorkflowActor::Human,
1954 payload: serde_json::json!({
1955 "target": { "kind": "run", "runId": run_id },
1956 "reason": "cancel mid tick",
1957 "by": "tester",
1958 }),
1959 timestamp: None,
1960 payload_hash: None,
1961 });
1962 });
1963 }
1964 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1965 *self.completed.lock().await += 1;
1966 Ok(WorkflowDispatchOutcome::Succeeded {
1967 output: Value::String(resolved_prompt),
1968 session: None,
1969 })
1970 }
1971
1972 async fn execute_host_executor(
1973 &mut self,
1974 _ctx: WorkflowDispatchRun<'_>,
1975 _node: &HostExecutorNode,
1976 resolved_input: Value,
1977 ) -> Result<WorkflowDispatchOutcome> {
1978 Ok(WorkflowDispatchOutcome::Succeeded {
1979 output: resolved_input,
1980 session: None,
1981 })
1982 }
1983 }
1984
1985 #[tokio::test]
1986 async fn run_tick_stops_between_actions_when_cancel_arrives() {
1987 let run_dir = temp_run_dir("cancel-mid-tick");
1988 let _ = fs::remove_dir_all(&run_dir);
1989 fs::create_dir_all(run_dir.join("blobs")).unwrap();
1990 let paths = crate::BeamPaths::from_root(run_dir.clone());
1991 let run_id = "run-cancel";
1992 crate::bootstrap_workflow_run(
1993 &paths,
1994 crate::BootstrapWorkflowRunInput {
1995 run_id,
1996 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"}}}"#,
1997 expected_workflow_id: Some("flow-cancel"),
1998 params: &BTreeMap::new(),
1999 initiator: "cli",
2000 chat_binding: Some(RunChatBinding {
2001 chat_id: "chat-1".to_string(),
2002 lark_app_id: "app-1".to_string(),
2003 }),
2004 },
2005 )
2006 .unwrap();
2007 let mut rt = WorkflowRuntimeContext {
2008 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2009 def: WorkflowDefinition {
2010 workflow_id: "flow-cancel".to_string(),
2011 version: 1,
2012 params: None,
2013 defaults: None,
2014 nodes: BTreeMap::from([
2015 (
2016 "a".to_string(),
2017 WorkflowNode::Subagent(SubagentNode {
2018 base: NodeBase {
2019 description: None,
2020 depends: None,
2021 human_gate: None,
2022 retry_policy: None,
2023 timeout_ms: None,
2024 max_output_bytes: None,
2025 output_schema: None,
2026 unsafe_allow_ungated: None,
2027 },
2028 bot: "bot-a".to_string(),
2029 prompt: Value::String("one".to_string()),
2030 working_dir: None,
2031 model_overrides: None,
2032 tool_policy: None,
2033 }),
2034 ),
2035 (
2036 "b".to_string(),
2037 WorkflowNode::Subagent(SubagentNode {
2038 base: NodeBase {
2039 description: None,
2040 depends: None,
2041 human_gate: None,
2042 retry_policy: None,
2043 timeout_ms: None,
2044 max_output_bytes: None,
2045 output_schema: None,
2046 unsafe_allow_ungated: None,
2047 },
2048 bot: "bot-b".to_string(),
2049 prompt: Value::String("two".to_string()),
2050 working_dir: None,
2051 model_overrides: None,
2052 tool_policy: None,
2053 }),
2054 ),
2055 ]),
2056 },
2057 runs_base_dir: paths.workflow_runs_dir(),
2058 };
2059 let mut hooks = CancellingHooks {
2060 run_id: run_id.to_string(),
2061 runs_base_dir: paths.workflow_runs_dir(),
2062 calls: Arc::new(Mutex::new(0)),
2063 completed: Arc::new(Mutex::new(0)),
2064 };
2065 let started = std::time::Instant::now();
2066 let tick = run_tick(&mut rt, &mut hooks, 2).await.unwrap();
2067 let elapsed = started.elapsed();
2068 assert!(tick.actions < 2);
2069 assert!(tick.snapshot.run.cancelled_run_intent.is_some());
2070 assert!(tick.snapshot.activities.len() >= 1);
2071 assert!(
2072 elapsed < std::time::Duration::from_millis(250),
2073 "run_tick took {:?}, expected cancel to interrupt long-running actions",
2074 elapsed
2075 );
2076 assert_eq!(*hooks.completed.lock().await, 0);
2077 let _ = fs::remove_dir_all(&run_dir);
2078 }
2079
2080 #[tokio::test]
2081 async fn run_tick_skips_dispatch_when_run_cancel_is_pending() {
2082 let run_dir = temp_run_dir("reconcile-cancel");
2083 let _ = fs::remove_dir_all(&run_dir);
2084 fs::create_dir_all(run_dir.join("blobs")).unwrap();
2085 let paths = crate::BeamPaths::from_root(run_dir.clone());
2086 let run_id = "run-reconcile-cancel";
2087 crate::bootstrap_workflow_run(
2088 &paths,
2089 crate::BootstrapWorkflowRunInput {
2090 run_id,
2091 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"}}}"#,
2092 expected_workflow_id: Some("flow-reconcile"),
2093 params: &BTreeMap::new(),
2094 initiator: "cli",
2095 chat_binding: Some(RunChatBinding {
2096 chat_id: "chat-1".to_string(),
2097 lark_app_id: "app-1".to_string(),
2098 }),
2099 },
2100 )
2101 .unwrap();
2102 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
2103 let _ = crate::request_cancel(
2104 &mut log,
2105 crate::RequestCancelInput {
2106 target: serde_json::json!({
2107 "kind": "run",
2108 "runId": run_id,
2109 }),
2110 reason: "cancel before dispatch".to_string(),
2111 by: "tester".to_string(),
2112 },
2113 WorkflowActor::Human,
2114 )
2115 .await
2116 .unwrap();
2117 let mut rt = WorkflowRuntimeContext {
2118 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2119 def: WorkflowDefinition {
2120 workflow_id: "flow-reconcile".to_string(),
2121 version: 1,
2122 params: None,
2123 defaults: None,
2124 nodes: BTreeMap::from([
2125 (
2126 "a".to_string(),
2127 WorkflowNode::Subagent(SubagentNode {
2128 base: NodeBase {
2129 description: None,
2130 depends: None,
2131 human_gate: None,
2132 retry_policy: None,
2133 timeout_ms: None,
2134 max_output_bytes: None,
2135 output_schema: None,
2136 unsafe_allow_ungated: None,
2137 },
2138 bot: "bot-a".to_string(),
2139 prompt: Value::String("one".to_string()),
2140 working_dir: None,
2141 model_overrides: None,
2142 tool_policy: None,
2143 }),
2144 ),
2145 (
2146 "b".to_string(),
2147 WorkflowNode::Subagent(SubagentNode {
2148 base: NodeBase {
2149 description: None,
2150 depends: None,
2151 human_gate: None,
2152 retry_policy: None,
2153 timeout_ms: None,
2154 max_output_bytes: None,
2155 output_schema: None,
2156 unsafe_allow_ungated: None,
2157 },
2158 bot: "bot-b".to_string(),
2159 prompt: Value::String("two".to_string()),
2160 working_dir: None,
2161 model_overrides: None,
2162 tool_policy: None,
2163 }),
2164 ),
2165 ]),
2166 },
2167 runs_base_dir: paths.workflow_runs_dir(),
2168 };
2169 let mut hooks = FakeHooks;
2170 let tick = run_tick(&mut rt, &mut hooks, 2).await.unwrap();
2171 assert_eq!(tick.actions, 0);
2172 assert!(tick.snapshot.run.cancelled_run_intent.is_some());
2173 let _ = fs::remove_dir_all(&run_dir);
2174 }
2175
2176 #[test]
2177 fn orchestrator_action_is_dispatch_classifies_correctly() {
2178 use crate::workflow_definition::{NodeBase, SubagentNode};
2179 use crate::workflow_orchestrator::OrchestratorAction;
2180 let dispatch = OrchestratorAction::DispatchWork {
2181 node_id: "n1".to_string(),
2182 activity_id: "a1".to_string(),
2183 node: crate::WorkflowNode::Subagent(SubagentNode {
2184 base: NodeBase {
2185 description: None,
2186 depends: None,
2187 human_gate: None,
2188 retry_policy: None,
2189 timeout_ms: None,
2190 max_output_bytes: None,
2191 output_schema: None,
2192 unsafe_allow_ungated: None,
2193 },
2194 bot: "b1".to_string(),
2195 prompt: serde_json::json!("p"),
2196 working_dir: None,
2197 model_overrides: None,
2198 tool_policy: None,
2199 }),
2200 };
2201 assert!(dispatch.is_dispatch());
2202
2203 let settle = OrchestratorAction::CompleteNodeSucceeded {
2204 node_id: "n1".to_string(),
2205 last_activity_id: "a1".to_string(),
2206 output_ref: None,
2207 };
2208 assert!(!settle.is_dispatch());
2209 }
2210
2211 #[tokio::test]
2212 async fn run_loop_stops_when_progress_is_exhausted() {
2213 let run_dir = temp_run_dir("loop");
2214 let _ = fs::remove_dir_all(&run_dir);
2215 fs::create_dir_all(run_dir.join("blobs")).unwrap();
2216 let paths = crate::BeamPaths::from_root(run_dir.clone());
2217 let params: BTreeMap<String, Value> =
2218 BTreeMap::from([(String::from("name"), Value::String("beam".to_string()))]);
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!(
4431 iter_started,
4432 "expected loopIterationStarted with iteration 1 for rl"
4433 );
4434 }
4435
4436 let _ = fs::remove_dir_all(&run_dir);
4437 }
4438
4439 #[tokio::test]
4440 async fn code_review_loop_reaches_human_gate_wait_with_correct_activity_id() {
4441 let run_dir = temp_run_dir("crl-gate");
4444 let _ = fs::remove_dir_all(&run_dir);
4445 fs::create_dir_all(run_dir.join("blobs")).unwrap();
4446 let paths = crate::BeamPaths::from_root(run_dir.clone());
4447 let run_id = "run-crl-gate";
4448 let def = code_review_loop_def();
4449 let workflow_json = serde_json::to_string(&def).unwrap();
4450 crate::bootstrap_workflow_run(
4451 &paths,
4452 crate::BootstrapWorkflowRunInput {
4453 run_id,
4454 workflow_json: &workflow_json,
4455 expected_workflow_id: Some("code-review-loop"),
4456 params: &BTreeMap::new(),
4457 initiator: "cli",
4458 chat_binding: Some(RunChatBinding {
4459 chat_id: "chat-1".to_string(),
4460 lark_app_id: "app-1".to_string(),
4461 }),
4462 },
4463 )
4464 .unwrap();
4465
4466 let mut rt = WorkflowRuntimeContext {
4467 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4468 def,
4469 runs_base_dir: paths.workflow_runs_dir(),
4470 };
4471 let mut hooks = FakeHooks;
4472 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4473
4474 assert_eq!(
4476 result.reason,
4477 RunLoopStopReason::AwaitingWait,
4478 "expected AwaitingWait for open human gate"
4479 );
4480
4481 let events = rt.log.read_all().unwrap();
4482
4483 let loop_started = events.iter().find(|e| e.event_type == "loopStarted");
4485 assert!(loop_started.is_some(), "missing loopStarted");
4486 let iter_started = events.iter().find(|e| {
4487 e.event_type == "loopIterationStarted"
4488 && e.payload.get("iteration") == Some(&Value::Number(1.into()))
4489 });
4490 assert!(iter_started.is_some(), "missing loopIterationStarted(1)");
4491
4492 let implement_work_id = format!("{}::loop::review-loop.1::work::implement", run_id);
4494 let review_work_id = format!("{}::loop::review-loop.1::work::review", run_id);
4495 let decision_gate_id = format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
4496
4497 let has_implement = events.iter().any(|e| {
4498 e.event_type == "attemptCreated"
4499 && e.payload.get("activityId").and_then(Value::as_str) == Some(&implement_work_id)
4500 });
4501 let has_review = events.iter().any(|e| {
4502 e.event_type == "attemptCreated"
4503 && e.payload.get("activityId").and_then(Value::as_str) == 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) == Some(&decision_gate_id)
4508 });
4509
4510 assert!(
4511 has_implement,
4512 "missing implement work dispatch: {}",
4513 implement_work_id
4514 );
4515 assert!(
4516 has_review,
4517 "missing review work dispatch: {}",
4518 review_work_id
4519 );
4520 assert!(
4521 has_decision_gate,
4522 "missing reviewDecision gate wait: {}",
4523 decision_gate_id
4524 );
4525
4526 let _ = fs::remove_dir_all(&run_dir);
4527 }
4528
4529 #[tokio::test]
4530 async fn reject_decision_enters_next_iteration() {
4531 let run_dir = temp_run_dir("crl-reject");
4534 let _ = fs::remove_dir_all(&run_dir);
4535 fs::create_dir_all(run_dir.join("blobs")).unwrap();
4536 let paths = crate::BeamPaths::from_root(run_dir.clone());
4537 let run_id = "run-crl-reject";
4538 let def = code_review_loop_def();
4539 let workflow_json = serde_json::to_string(&def).unwrap();
4540 crate::bootstrap_workflow_run(
4541 &paths,
4542 crate::BootstrapWorkflowRunInput {
4543 run_id,
4544 workflow_json: &workflow_json,
4545 expected_workflow_id: Some("code-review-loop"),
4546 params: &BTreeMap::new(),
4547 initiator: "cli",
4548 chat_binding: Some(RunChatBinding {
4549 chat_id: "chat-1".to_string(),
4550 lark_app_id: "app-1".to_string(),
4551 }),
4552 },
4553 )
4554 .unwrap();
4555
4556 let decision_gate_id = format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
4557
4558 {
4560 let mut rt = WorkflowRuntimeContext {
4561 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4562 def: def.clone(),
4563 runs_base_dir: paths.workflow_runs_dir(),
4564 };
4565 let mut hooks = FakeHooks;
4566 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4567 assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4568 }
4569
4570 {
4572 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4573 let _ = log
4574 .append(EventDraft {
4575 event_type: "waitResolved".to_string(),
4576 actor: WorkflowActor::Human,
4577 payload: serde_json::json!({
4578 "activityId": &decision_gate_id,
4579 "resolution": "rejected",
4580 "by": "reviewer",
4581 "comment": "needs work",
4582 }),
4583 timestamp: None,
4584 payload_hash: None,
4585 })
4586 .unwrap();
4587 }
4588
4589 {
4591 let mut rt = WorkflowRuntimeContext {
4592 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4593 def,
4594 runs_base_dir: paths.workflow_runs_dir(),
4595 };
4596 let mut hooks = FakeHooks;
4597 let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4598
4599 let events = rt.log.read_all().unwrap();
4600
4601 let iter1_finished = events.iter().any(|e| {
4603 e.event_type == "loopIterationFinished"
4604 && e.payload.get("iteration").and_then(Value::as_u64) == Some(1)
4605 && e.payload.get("resolution").and_then(Value::as_str) == Some("rejected")
4606 });
4607 assert!(
4608 iter1_finished,
4609 "expected loopIterationFinished with resolution=rejected for iteration 1"
4610 );
4611
4612 let iter2_started = events.iter().any(|e| {
4614 e.event_type == "loopIterationStarted"
4615 && e.payload.get("iteration").and_then(Value::as_u64) == Some(2)
4616 });
4617 assert!(
4618 iter2_started,
4619 "expected loopIterationStarted with iteration=2"
4620 );
4621
4622 let implement_work_v2 = format!("{}::loop::review-loop.2::work::implement", run_id);
4624 let has_iter2_implement = events.iter().any(|e| {
4625 e.event_type == "attemptCreated"
4626 && e.payload.get("activityId").and_then(Value::as_str)
4627 == Some(&implement_work_v2)
4628 });
4629 assert!(
4630 has_iter2_implement,
4631 "expected iteration 2 to dispatch implement work: {}",
4632 implement_work_v2
4633 );
4634 }
4635
4636 let _ = fs::remove_dir_all(&run_dir);
4637 }
4638
4639 #[tokio::test]
4640 async fn approve_decision_finishes_loop_and_run_succeeds() {
4641 let run_dir = temp_run_dir("crl-approve");
4644 let _ = fs::remove_dir_all(&run_dir);
4645 fs::create_dir_all(run_dir.join("blobs")).unwrap();
4646 let paths = crate::BeamPaths::from_root(run_dir.clone());
4647 let run_id = "run-crl-approve";
4648 let def = code_review_loop_def();
4649 let workflow_json = serde_json::to_string(&def).unwrap();
4650 crate::bootstrap_workflow_run(
4651 &paths,
4652 crate::BootstrapWorkflowRunInput {
4653 run_id,
4654 workflow_json: &workflow_json,
4655 expected_workflow_id: Some("code-review-loop"),
4656 params: &BTreeMap::new(),
4657 initiator: "cli",
4658 chat_binding: Some(RunChatBinding {
4659 chat_id: "chat-1".to_string(),
4660 lark_app_id: "app-1".to_string(),
4661 }),
4662 },
4663 )
4664 .unwrap();
4665
4666 let decision_gate_id = format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
4667
4668 {
4670 let mut rt = WorkflowRuntimeContext {
4671 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4672 def: def.clone(),
4673 runs_base_dir: paths.workflow_runs_dir(),
4674 };
4675 let mut hooks = FakeHooks;
4676 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4677 assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4678 }
4679
4680 {
4682 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4683 let _ = log
4684 .append(EventDraft {
4685 event_type: "waitResolved".to_string(),
4686 actor: WorkflowActor::Human,
4687 payload: serde_json::json!({
4688 "activityId": &decision_gate_id,
4689 "resolution": "approved",
4690 "by": "reviewer",
4691 "comment": "lgtm",
4692 }),
4693 timestamp: None,
4694 payload_hash: None,
4695 })
4696 .unwrap();
4697 }
4698
4699 {
4701 let mut rt = WorkflowRuntimeContext {
4702 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4703 def,
4704 runs_base_dir: paths.workflow_runs_dir(),
4705 };
4706 let mut hooks = FakeHooks;
4707 let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4708
4709 let events = rt.log.read_all().unwrap();
4710
4711 let loop_finished = events.iter().any(|e| {
4713 e.event_type == "loopFinished"
4714 && e.payload.get("loopId").and_then(Value::as_str) == Some("review-loop")
4715 && e.payload.get("resolution").and_then(Value::as_str) == Some("approved")
4716 });
4717 assert!(
4718 loop_finished,
4719 "expected loopFinished with resolution=approved"
4720 );
4721
4722 let run_succeeded = events.iter().any(|e| e.event_type == "runSucceeded");
4724 assert!(run_succeeded, "expected run to succeed after loop approval");
4725
4726 let snap = read_snapshot(&rt).await.unwrap();
4728 let loop_output_key = format!("{}::work::review-loop", run_id);
4729 assert!(
4730 snap.outputs.contains_key(&loop_output_key),
4731 "expected loop output under {}",
4732 loop_output_key
4733 );
4734 }
4735
4736 let _ = fs::remove_dir_all(&run_dir);
4737 }
4738
4739 #[derive(Clone)]
4741 struct FailingBodyHooks;
4742
4743 #[async_trait]
4744 impl WorkflowExecutionHooks for FailingBodyHooks {
4745 async fn execute_subagent(
4746 &mut self,
4747 _ctx: WorkflowDispatchRun<'_>,
4748 _node: &SubagentNode,
4749 _resolved_prompt: String,
4750 ) -> Result<WorkflowDispatchOutcome> {
4751 Ok(WorkflowDispatchOutcome::Failed {
4752 error_code: "TestFailure".to_string(),
4753 error_class: "fatal".to_string(),
4754 error_message: "simulated body failure".to_string(),
4755 session: None,
4756 })
4757 }
4758
4759 async fn execute_host_executor(
4760 &mut self,
4761 _ctx: WorkflowDispatchRun<'_>,
4762 _node: &HostExecutorNode,
4763 _resolved_input: Value,
4764 ) -> Result<WorkflowDispatchOutcome> {
4765 Ok(WorkflowDispatchOutcome::Failed {
4766 error_code: "TestFailure".to_string(),
4767 error_class: "fatal".to_string(),
4768 error_message: "simulated body failure".to_string(),
4769 session: None,
4770 })
4771 }
4772 }
4773
4774 #[tokio::test]
4775 async fn body_failure_causes_loop_failed() {
4776 let run_dir = temp_run_dir("crl-body-fail");
4779 let _ = fs::remove_dir_all(&run_dir);
4780 fs::create_dir_all(run_dir.join("blobs")).unwrap();
4781 let paths = crate::BeamPaths::from_root(run_dir.clone());
4782 let run_id = "run-crl-body-fail";
4783 let def = code_review_loop_def();
4784 let workflow_json = serde_json::to_string(&def).unwrap();
4785 crate::bootstrap_workflow_run(
4786 &paths,
4787 crate::BootstrapWorkflowRunInput {
4788 run_id,
4789 workflow_json: &workflow_json,
4790 expected_workflow_id: Some("code-review-loop"),
4791 params: &BTreeMap::new(),
4792 initiator: "cli",
4793 chat_binding: Some(RunChatBinding {
4794 chat_id: "chat-1".to_string(),
4795 lark_app_id: "app-1".to_string(),
4796 }),
4797 },
4798 )
4799 .unwrap();
4800
4801 let mut rt = WorkflowRuntimeContext {
4802 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4803 def,
4804 runs_base_dir: paths.workflow_runs_dir(),
4805 };
4806 let mut hooks = FailingBodyHooks;
4807 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4808
4809 let events = rt.log.read_all().unwrap();
4810
4811 let loop_started = events.iter().any(|e| e.event_type == "loopStarted");
4813 assert!(loop_started, "expected loopStarted");
4814
4815 let iter_started = events.iter().any(|e| {
4816 e.event_type == "loopIterationStarted"
4817 && e.payload.get("iteration").and_then(Value::as_u64) == Some(1)
4818 });
4819 assert!(iter_started, "expected loopIterationStarted(1)");
4820
4821 let implement_work_id = format!("{}::loop::review-loop.1::work::implement", run_id);
4823 let has_implement_fail = events.iter().any(|e| {
4824 e.event_type == "activityFailed"
4825 && e.payload.get("activityId").and_then(Value::as_str) == Some(&implement_work_id)
4826 });
4827 assert!(
4828 has_implement_fail,
4829 "expected implement work to fail: {}",
4830 implement_work_id
4831 );
4832
4833 let loop_failed = events.iter().any(|e| {
4835 e.event_type == "loopFinished"
4836 && e.payload.get("loopId").and_then(Value::as_str) == Some("review-loop")
4837 && e.payload.get("resolution").and_then(Value::as_str) == Some("failed")
4838 });
4839 assert!(
4840 loop_failed,
4841 "expected loopFinished with resolution=failed after body failure"
4842 );
4843
4844 assert!(
4846 matches!(result.reason, RunLoopStopReason::Terminal),
4847 "expected terminal; reason={:?}",
4848 result.reason
4849 );
4850 let run_failed = events.iter().any(|e| e.event_type == "runFailed");
4851 assert!(run_failed, "expected run to fail after loop body failure");
4852
4853 let _ = fs::remove_dir_all(&run_dir);
4854 }
4855
4856 #[tokio::test]
4857 async fn max_iterations_reject_causes_loop_failed() {
4858 let run_dir = temp_run_dir("crl-maxiter");
4861 let _ = fs::remove_dir_all(&run_dir);
4862 fs::create_dir_all(run_dir.join("blobs")).unwrap();
4863 let paths = crate::BeamPaths::from_root(run_dir.clone());
4864 let run_id = "run-crl-maxiter";
4865 let def = code_review_loop_def();
4866 let workflow_json = serde_json::to_string(&def).unwrap();
4867 crate::bootstrap_workflow_run(
4868 &paths,
4869 crate::BootstrapWorkflowRunInput {
4870 run_id,
4871 workflow_json: &workflow_json,
4872 expected_workflow_id: Some("code-review-loop"),
4873 params: &BTreeMap::new(),
4874 initiator: "cli",
4875 chat_binding: Some(RunChatBinding {
4876 chat_id: "chat-1".to_string(),
4877 lark_app_id: "app-1".to_string(),
4878 }),
4879 },
4880 )
4881 .unwrap();
4882
4883 let iter1_gate_id = format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
4888
4889 {
4891 let mut rt = WorkflowRuntimeContext {
4892 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4893 def: def.clone(),
4894 runs_base_dir: paths.workflow_runs_dir(),
4895 };
4896 let mut hooks = FakeHooks;
4897 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4898 assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4899 }
4900 {
4902 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4903 let _ = log
4904 .append(EventDraft {
4905 event_type: "waitResolved".to_string(),
4906 actor: WorkflowActor::Human,
4907 payload: serde_json::json!({
4908 "activityId": &iter1_gate_id,
4909 "resolution": "rejected",
4910 "by": "reviewer",
4911 "comment": "redo",
4912 }),
4913 timestamp: None,
4914 payload_hash: None,
4915 })
4916 .unwrap();
4917 }
4918 {
4920 let mut rt = WorkflowRuntimeContext {
4921 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4922 def: def.clone(),
4923 runs_base_dir: paths.workflow_runs_dir(),
4924 };
4925 let mut hooks = FakeHooks;
4926 let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4927
4928 let events = rt.log.read_all().unwrap();
4929 let iter2_started = events.iter().any(|e| {
4930 e.event_type == "loopIterationStarted"
4931 && e.payload.get("iteration").and_then(Value::as_u64) == Some(2)
4932 });
4933 assert!(iter2_started, "expected iteration 2 started");
4934 }
4935
4936 {
4938 let mut rt = WorkflowRuntimeContext {
4939 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4940 def: def.clone(),
4941 runs_base_dir: paths.workflow_runs_dir(),
4942 };
4943 let mut hooks = FakeHooks;
4944 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4945 assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4946 }
4947 {
4949 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4950 let iter2_gate_id = format!("{}::loop::review-loop.2::gate::reviewDecision", run_id);
4951 let _ = log
4952 .append(EventDraft {
4953 event_type: "waitResolved".to_string(),
4954 actor: WorkflowActor::Human,
4955 payload: serde_json::json!({
4956 "activityId": &iter2_gate_id,
4957 "resolution": "rejected",
4958 "by": "reviewer",
4959 "comment": "still no",
4960 }),
4961 timestamp: None,
4962 payload_hash: None,
4963 })
4964 .unwrap();
4965 }
4966 {
4968 let mut rt = WorkflowRuntimeContext {
4969 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4970 def: def.clone(),
4971 runs_base_dir: paths.workflow_runs_dir(),
4972 };
4973 let mut hooks = FakeHooks;
4974 let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4975
4976 let events = rt.log.read_all().unwrap();
4977 let iter3_started = events.iter().any(|e| {
4978 e.event_type == "loopIterationStarted"
4979 && e.payload.get("iteration").and_then(Value::as_u64) == Some(3)
4980 });
4981 assert!(iter3_started, "expected iteration 3 started");
4982 }
4983
4984 {
4986 let mut rt = WorkflowRuntimeContext {
4987 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4988 def: def.clone(),
4989 runs_base_dir: paths.workflow_runs_dir(),
4990 };
4991 let mut hooks = FakeHooks;
4992 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4993 assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4994 }
4995 {
4997 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4998 let iter3_gate_id = format!("{}::loop::review-loop.3::gate::reviewDecision", run_id);
4999 let _ = log
5000 .append(EventDraft {
5001 event_type: "waitResolved".to_string(),
5002 actor: WorkflowActor::Human,
5003 payload: serde_json::json!({
5004 "activityId": &iter3_gate_id,
5005 "resolution": "rejected",
5006 "by": "reviewer",
5007 "comment": "still no",
5008 }),
5009 timestamp: None,
5010 payload_hash: None,
5011 })
5012 .unwrap();
5013 }
5014 {
5016 let mut rt = WorkflowRuntimeContext {
5017 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5018 def,
5019 runs_base_dir: paths.workflow_runs_dir(),
5020 };
5021 let mut hooks = FakeHooks;
5022 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5023
5024 let events = rt.log.read_all().unwrap();
5025
5026 let loop_failed = events.iter().any(|e| {
5028 e.event_type == "loopFinished"
5029 && e.payload.get("loopId").and_then(Value::as_str) == Some("review-loop")
5030 && e.payload.get("resolution").and_then(Value::as_str) == Some("failed")
5031 && e.payload.get("errorCode").and_then(Value::as_str)
5032 == Some("MaxIterationsReached")
5033 });
5034 assert!(
5035 loop_failed,
5036 "expected loopFinished with failed/MaxIterationsReached"
5037 );
5038
5039 assert!(
5040 matches!(result.reason, RunLoopStopReason::Terminal),
5041 "expected terminal; reason={:?}",
5042 result.reason
5043 );
5044 let run_failed = events.iter().any(|e| e.event_type == "runFailed");
5045 assert!(
5046 run_failed,
5047 "expected run to fail when max iterations reached"
5048 );
5049 }
5050
5051 let _ = fs::remove_dir_all(&run_dir);
5052 }
5053
5054 #[derive(Clone)]
5060 struct RichFakeHooks;
5061
5062 #[async_trait]
5063 impl WorkflowExecutionHooks for RichFakeHooks {
5064 async fn execute_subagent(
5065 &mut self,
5066 _ctx: WorkflowDispatchRun<'_>,
5067 _node: &SubagentNode,
5068 _resolved_prompt: String,
5069 ) -> Result<WorkflowDispatchOutcome> {
5070 Ok(WorkflowDispatchOutcome::Succeeded {
5071 output: serde_json::json!({"code": "mock-code", "summary": "mock-summary", "preview": "looks good"}),
5072 session: None,
5073 })
5074 }
5075
5076 async fn execute_host_executor(
5077 &mut self,
5078 _ctx: WorkflowDispatchRun<'_>,
5079 _node: &HostExecutorNode,
5080 resolved_input: Value,
5081 ) -> Result<WorkflowDispatchOutcome> {
5082 Ok(WorkflowDispatchOutcome::Succeeded {
5083 output: resolved_input,
5084 session: None,
5085 })
5086 }
5087 }
5088
5089 #[derive(Clone)]
5091 struct RecordingHooks {
5092 prompts: Arc<Mutex<Vec<(String, String)>>>, }
5094
5095 #[async_trait]
5096 impl WorkflowExecutionHooks for RecordingHooks {
5097 async fn execute_subagent(
5098 &mut self,
5099 _ctx: WorkflowDispatchRun<'_>,
5100 _node: &SubagentNode,
5101 resolved_prompt: String,
5102 ) -> Result<WorkflowDispatchOutcome> {
5103 self.prompts
5104 .lock()
5105 .await
5106 .push((_ctx.node_id.to_string(), resolved_prompt.clone()));
5107 let out = serde_json::json!({"code": "mock-code", "summary": "mock-summary", "preview": "looks good"});
5111 Ok(WorkflowDispatchOutcome::Succeeded {
5112 output: out,
5113 session: None,
5114 })
5115 }
5116
5117 async fn execute_host_executor(
5118 &mut self,
5119 _ctx: WorkflowDispatchRun<'_>,
5120 _node: &HostExecutorNode,
5121 resolved_input: Value,
5122 ) -> Result<WorkflowDispatchOutcome> {
5123 Ok(WorkflowDispatchOutcome::Succeeded {
5124 output: resolved_input,
5125 session: None,
5126 })
5127 }
5128 }
5129
5130 #[tokio::test]
5131 async fn real_code_review_loop_iter1_reaches_awaiting_wait() {
5132 let raw = include_str!("../../../workflows/code-review-loop.workflow.json");
5136 let def = crate::parse_workflow_definition(raw).expect("parse real code-review-loop");
5137 let workflow_json = serde_json::to_string(&def).unwrap();
5138
5139 let run_dir = temp_run_dir("real-crl-iter1");
5140 let _ = fs::remove_dir_all(&run_dir);
5141 fs::create_dir_all(run_dir.join("blobs")).unwrap();
5142 let paths = crate::BeamPaths::from_root(run_dir.clone());
5143 let run_id = "run-real-crl-1";
5144 let params: BTreeMap<String, Value> = BTreeMap::from([(
5145 String::from("task"),
5146 Value::String("add CLI echo command".to_string()),
5147 )]);
5148 crate::bootstrap_workflow_run(
5149 &paths,
5150 crate::BootstrapWorkflowRunInput {
5151 run_id,
5152 workflow_json: &workflow_json,
5153 expected_workflow_id: Some("code-review-loop"),
5154 params: ¶ms,
5155 initiator: "cli",
5156 chat_binding: Some(RunChatBinding {
5157 chat_id: "chat-1".to_string(),
5158 lark_app_id: "app-1".to_string(),
5159 }),
5160 },
5161 )
5162 .unwrap();
5163
5164 let prompts = Arc::new(Mutex::new(Vec::new()));
5165 let mut hooks = RecordingHooks {
5166 prompts: prompts.clone(),
5167 };
5168
5169 let mut rt = WorkflowRuntimeContext {
5170 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5171 def,
5172 runs_base_dir: paths.workflow_runs_dir(),
5173 };
5174 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5175
5176 assert_eq!(
5177 result.reason,
5178 RunLoopStopReason::AwaitingWait,
5179 "expected AwaitingWait for open human gate"
5180 );
5181
5182 let recorded = prompts.lock().await;
5185 let implement_prompt = recorded
5186 .iter()
5187 .find(|(node_id, _)| node_id == "implement")
5188 .map(|(_, p)| p.clone())
5189 .expect("implement must have been dispatched");
5190 eprintln!("implement prompt iter1: {implement_prompt}");
5191 assert!(
5192 implement_prompt.contains("add CLI echo command"),
5193 "implement prompt should contain the task param"
5194 );
5195 assert!(
5197 !implement_prompt.contains("ERROR")
5198 && !implement_prompt.contains("no previous iteration"),
5199 "implement prompt should not contain binding errors: {implement_prompt}"
5200 );
5201
5202 let events = rt.log.read_all().unwrap();
5204 let decision_gate_id = format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
5205 let has_gate_wait = events.iter().any(|e| {
5206 e.event_type == "waitCreated"
5207 && e.payload.get("activityId").and_then(Value::as_str) == Some(&decision_gate_id)
5208 });
5209 assert!(has_gate_wait, "expected gate wait for {}", decision_gate_id);
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> =
5229 BTreeMap::from([(String::from("task"), Value::String("add test".to_string()))]);
5230 crate::bootstrap_workflow_run(
5231 &paths,
5232 crate::BootstrapWorkflowRunInput {
5233 run_id,
5234 workflow_json: &workflow_json,
5235 expected_workflow_id: Some("code-review-loop"),
5236 params: ¶ms,
5237 initiator: "cli",
5238 chat_binding: Some(RunChatBinding {
5239 chat_id: "chat-1".to_string(),
5240 lark_app_id: "app-1".to_string(),
5241 }),
5242 },
5243 )
5244 .unwrap();
5245
5246 let prompts = Arc::new(Mutex::new(Vec::new()));
5247
5248 {
5250 let mut hooks = RecordingHooks {
5251 prompts: prompts.clone(),
5252 };
5253 let mut rt = WorkflowRuntimeContext {
5254 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5255 def: def.clone(),
5256 runs_base_dir: paths.workflow_runs_dir(),
5257 };
5258 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5259 assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
5260 }
5261
5262 let decision_gate_id = format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
5264 {
5265 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
5266 let _ = log
5267 .append(EventDraft {
5268 event_type: "waitResolved".to_string(),
5269 actor: WorkflowActor::Human,
5270 payload: serde_json::json!({
5271 "activityId": &decision_gate_id,
5272 "resolution": "rejected",
5273 "by": "reviewer",
5274 "comment": "needs more tests",
5275 }),
5276 timestamp: None,
5277 payload_hash: None,
5278 })
5279 .unwrap();
5280 }
5281
5282 {
5284 let mut hooks = RecordingHooks {
5285 prompts: prompts.clone(),
5286 };
5287 let mut rt = WorkflowRuntimeContext {
5288 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5289 def: def.clone(),
5290 runs_base_dir: paths.workflow_runs_dir(),
5291 };
5292 let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5293
5294 let events = rt.log.read_all().unwrap();
5296 let iter1_finished = events.iter().find(|e| {
5297 e.event_type == "loopIterationFinished"
5298 && e.payload.get("iteration").and_then(Value::as_u64) == Some(1)
5299 });
5300 assert!(
5301 iter1_finished.is_some(),
5302 "expected loopIterationFinished for iteration 1"
5303 );
5304 let payload = &iter1_finished.unwrap().payload;
5305 assert_eq!(
5306 payload.get("resolution").and_then(Value::as_str),
5307 Some("rejected"),
5308 "resolution should be rejected"
5309 );
5310 assert_eq!(
5311 payload.get("by").and_then(Value::as_str),
5312 Some("reviewer"),
5313 "by should be reviewer"
5314 );
5315 assert_eq!(
5316 payload.get("comment").and_then(Value::as_str),
5317 Some("needs more tests"),
5318 "comment should be preserved"
5319 );
5320 assert_eq!(
5321 payload.get("decisionActivityId").and_then(Value::as_str),
5322 Some(decision_gate_id.as_str()),
5323 "decisionActivityId should be the gate id"
5324 );
5325
5326 let iter2_started = events.iter().any(|e| {
5327 e.event_type == "loopIterationStarted"
5328 && e.payload.get("iteration").and_then(Value::as_u64) == Some(2)
5329 });
5330 assert!(iter2_started, "expected iteration 2 started");
5331 }
5332
5333 let recorded = prompts.lock().await;
5337 let iter2_implement = recorded
5338 .iter()
5339 .filter(|(node_id, _)| node_id == "implement")
5340 .last()
5341 .map(|(_, p)| p.clone())
5342 .expect("implement iter2 must have been dispatched");
5343 eprintln!("implement prompt iter2: {iter2_implement}");
5344 assert!(
5345 iter2_implement.contains("needs more tests"),
5346 "iter2 implement prompt should contain reject comment 'needs more tests': {iter2_implement}"
5347 );
5348
5349 let _ = fs::remove_dir_all(&run_dir);
5350 }
5351
5352 #[tokio::test]
5353 async fn real_code_review_loop_approve_succeeds() {
5354 let raw = include_str!("../../../workflows/code-review-loop.workflow.json");
5356 let def = crate::parse_workflow_definition(raw).expect("parse real code-review-loop");
5357 let workflow_json = serde_json::to_string(&def).unwrap();
5358
5359 let run_dir = temp_run_dir("real-crl-approve");
5360 let _ = fs::remove_dir_all(&run_dir);
5361 fs::create_dir_all(run_dir.join("blobs")).unwrap();
5362 let paths = crate::BeamPaths::from_root(run_dir.clone());
5363 let run_id = "run-real-crl-app";
5364 let params: BTreeMap<String, Value> = BTreeMap::from([(
5365 String::from("task"),
5366 Value::String("add feature".to_string()),
5367 )]);
5368 crate::bootstrap_workflow_run(
5369 &paths,
5370 crate::BootstrapWorkflowRunInput {
5371 run_id,
5372 workflow_json: &workflow_json,
5373 expected_workflow_id: Some("code-review-loop"),
5374 params: ¶ms,
5375 initiator: "cli",
5376 chat_binding: Some(RunChatBinding {
5377 chat_id: "chat-1".to_string(),
5378 lark_app_id: "app-1".to_string(),
5379 }),
5380 },
5381 )
5382 .unwrap();
5383
5384 {
5386 let mut hooks = RichFakeHooks;
5387 let mut rt = WorkflowRuntimeContext {
5388 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5389 def: def.clone(),
5390 runs_base_dir: paths.workflow_runs_dir(),
5391 };
5392 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5393 assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
5394 }
5395
5396 let decision_gate_id = format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
5398 {
5399 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
5400 let _ = log
5401 .append(EventDraft {
5402 event_type: "waitResolved".to_string(),
5403 actor: WorkflowActor::Human,
5404 payload: serde_json::json!({
5405 "activityId": &decision_gate_id,
5406 "resolution": "approved",
5407 "by": "approver",
5408 "comment": "lgtm",
5409 }),
5410 timestamp: None,
5411 payload_hash: None,
5412 })
5413 .unwrap();
5414 }
5415
5416 {
5418 let mut hooks = RichFakeHooks;
5419 let mut rt = WorkflowRuntimeContext {
5420 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5421 def,
5422 runs_base_dir: paths.workflow_runs_dir(),
5423 };
5424 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5425
5426 let events = rt.log.read_all().unwrap();
5427
5428 let loop_finished = events.iter().find(|e| {
5429 e.event_type == "loopFinished"
5430 && e.payload.get("loopId").and_then(Value::as_str) == Some("review-loop")
5431 });
5432 assert!(loop_finished.is_some(), "expected loopFinished");
5433 let payload = &loop_finished.unwrap().payload;
5434 assert_eq!(
5435 payload.get("resolution").and_then(Value::as_str),
5436 Some("approved")
5437 );
5438
5439 let run_succeeded = events.iter().any(|e| e.event_type == "runSucceeded");
5440 assert!(run_succeeded, "expected run to succeed after loop approval");
5441
5442 assert!(matches!(result.reason, RunLoopStopReason::Terminal));
5443
5444 let snap = read_snapshot(&rt).await.unwrap();
5446 let loop_output_key = format!("{}::work::review-loop", run_id);
5447 assert!(
5448 snap.outputs.contains_key(&loop_output_key),
5449 "expected loop output"
5450 );
5451 }
5452
5453 let _ = fs::remove_dir_all(&run_dir);
5454 }
5455}