1use std::fs;
2use std::path::PathBuf;
3use std::time::{Duration, Instant};
4
5use anyhow::{Context, Result};
6use async_trait::async_trait;
7use serde_json::Value;
8use sha2::{Digest, Sha256};
9use tokio::task::JoinSet;
10use tokio::time::MissedTickBehavior;
11
12use crate::workflow_binding::{
13 BindingContext, LoopContext, resolve_bindings, resolve_bound_string,
14};
15use crate::workflow_definition::{HostExecutorNode, SubagentNode};
16use crate::workflow_orchestrator::OrchestratorAction;
17use crate::workflow_sidecar::write_effect_input_sidecar;
18use crate::{
19 EventDraft, EventLog, RunSnapshotDTO, RunStatus, WorkflowActor, WorkflowDefinition,
20 WorkflowNode, WorkflowOutputRef, decide_next_actions, read_run_snapshot,
21};
22
23#[derive(Debug, Clone)]
24pub struct WorkflowRuntimeContext {
25 pub log: EventLog,
26 pub def: WorkflowDefinition,
27 pub runs_base_dir: PathBuf,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct WorkflowDispatchSession {
32 pub session_id: String,
33 pub bot_name: String,
34 pub started_at: u64,
35 pub ended_at: Option<u64>,
36 pub cli_session_id: Option<String>,
37 pub lark_app_id: Option<String>,
38 pub cli_id: Option<String>,
39 pub working_dir: Option<String>,
40 pub web_port: Option<u16>,
41 pub log_path: Option<String>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum WorkflowDispatchOutcome {
46 Succeeded {
47 output: Value,
48 session: Option<WorkflowDispatchSession>,
49 },
50 Failed {
51 error_code: String,
52 error_class: String,
53 error_message: String,
54 session: Option<WorkflowDispatchSession>,
55 },
56 Cancelled {
57 cancel_origin_event_id: String,
58 session: Option<WorkflowDispatchSession>,
59 },
60}
61
62#[derive(Debug, Clone)]
63pub struct WorkflowDispatchRun<'a> {
64 pub run_id: &'a str,
65 pub workflow_id: &'a str,
66 pub revision_id: &'a str,
67 pub activity_id: &'a str,
68 pub attempt_id: &'a str,
69 pub node_id: &'a str,
70}
71
72#[derive(Debug, Clone)]
77pub struct HostExecutorPrepareResult {
78 pub parsed_input: Value,
80 pub canonical_input: Value,
83 pub provider: String,
85 pub idempotency_ttl_ms: u64,
87}
88
89#[async_trait]
90pub trait WorkflowExecutionHooks {
91 async fn execute_subagent(
92 &mut self,
93 ctx: WorkflowDispatchRun<'_>,
94 node: &SubagentNode,
95 resolved_prompt: String,
96 ) -> Result<WorkflowDispatchOutcome>;
97
98 async fn execute_host_executor(
99 &mut self,
100 ctx: WorkflowDispatchRun<'_>,
101 node: &HostExecutorNode,
102 parsed_input: Value,
104 ) -> Result<WorkflowDispatchOutcome>;
105
106 fn prepare_host_executor(
115 &self,
116 executor_name: &str,
117 resolved_input: &Value,
118 ) -> Result<HostExecutorPrepareResult> {
119 let (provider, idempotency_ttl_ms) = get_host_executor_provider_meta(executor_name);
120 Ok(HostExecutorPrepareResult {
121 parsed_input: resolved_input.clone(),
122 canonical_input: resolved_input.clone(),
123 provider: provider.to_string(),
124 idempotency_ttl_ms,
125 })
126 }
127
128 async fn recover_dangling_effects(
142 &mut self,
143 _log: &mut EventLog,
144 snapshot: &RunSnapshotDTO,
145 ) -> Result<RecoveryResult> {
146 Ok(RecoveryResult {
147 had_progress: false,
148 has_remaining_dangling: !snapshot.dangling.effect_attempted.is_empty(),
149 })
150 }
151
152 async fn on_activities_cancelled(
159 &mut self,
160 _activity_ids: &[String],
161 _node_ids: &[String],
162 _run_id: &str,
163 ) {
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct RunTickResult {
169 pub actions: usize,
170 pub snapshot: RunSnapshotDTO,
171}
172
173#[derive(Debug, Clone)]
174struct ScheduledAction {
175 action: OrchestratorAction,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub enum RunLoopStopReason {
180 Terminal,
181 NoProgress,
182 AwaitingWait,
183 MaxTicks,
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct RecoveryResult {
189 pub had_progress: bool,
191 pub has_remaining_dangling: bool,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct RunLoopResult {
197 pub reason: RunLoopStopReason,
198 pub ticks: usize,
199 pub last_snapshot: RunSnapshotDTO,
200}
201
202pub async fn run_tick<H: WorkflowExecutionHooks + Clone + Send + 'static>(
203 rt: &mut WorkflowRuntimeContext,
204 hooks: &mut H,
205 max_concurrency: usize,
206) -> Result<RunTickResult> {
207 let snapshot = read_snapshot(rt).await?;
208 if matches!(
209 snapshot.run.status,
210 RunStatus::Succeeded | RunStatus::Failed | RunStatus::Cancelled
211 ) {
212 return Ok(RunTickResult {
213 actions: 0,
214 snapshot,
215 });
216 }
217
218 if snapshot_has_pending_cancel(&snapshot) {
219 return Ok(RunTickResult {
220 actions: 0,
221 snapshot,
222 });
223 }
224
225 let actions = select_tick_actions(
226 decide_next_actions(&snapshot, &rt.def),
227 &rt.def,
228 max_concurrency,
229 );
230 if actions.is_empty() {
231 return Ok(RunTickResult {
232 actions: 0,
233 snapshot,
234 });
235 }
236
237 let mut join_set: JoinSet<Result<()>> = JoinSet::new();
238 for scheduled in actions.into_iter() {
239 let mut rt_clone = rt.clone();
240 let mut hooks_clone = hooks.clone();
241 join_set.spawn(async move {
242 apply_orchestrator_action(&mut rt_clone, &mut hooks_clone, scheduled.action).await
243 });
244 }
245
246 let mut applied = 0usize;
247 let mut cancel_poll = tokio::time::interval(Duration::from_millis(20));
248 cancel_poll.set_missed_tick_behavior(MissedTickBehavior::Skip);
249 let mut cancel_seen = false;
250 let mut cancel_abort_deadline: Option<Instant> = None;
251
252 while !join_set.is_empty() {
253 tokio::select! {
254 result = join_set.join_next() => {
255 let Some(result) = result else {
256 break;
257 };
258 match result {
259 Ok(Ok(())) => {
260 applied += 1;
261 let snapshot = read_snapshot(rt).await?;
262 if snapshot_has_pending_cancel(&snapshot)
263 || matches!(
264 snapshot.run.status,
265 RunStatus::Succeeded | RunStatus::Failed | RunStatus::Cancelled
266 )
267 {
268 if snapshot_has_pending_cancel(&snapshot) && !cancel_seen {
269 cancel_seen = true;
270 cancel_abort_deadline =
271 Some(Instant::now() + Duration::from_millis(120));
272 }
273 if matches!(
274 snapshot.run.status,
275 RunStatus::Succeeded | RunStatus::Failed | RunStatus::Cancelled
276 ) {
277 join_set.abort_all();
278 return Ok(RunTickResult {
279 actions: applied,
280 snapshot,
281 });
282 }
283 }
284 }
285 Ok(Err(err)) => {
286 if cancel_seen {
287 continue;
288 }
289 join_set.abort_all();
290 return Err(err);
291 }
292 Err(err) => {
293 if cancel_seen {
294 continue;
295 }
296 join_set.abort_all();
297 return Err(anyhow::anyhow!(err));
298 }
299 }
300 }
301 _ = cancel_poll.tick(), if !cancel_seen => {
302 let snapshot = read_snapshot(rt).await?;
303 if snapshot_has_pending_cancel(&snapshot) {
304 cancel_seen = true;
305 cancel_abort_deadline = Some(Instant::now() + Duration::from_millis(120));
306 join_set.abort_all();
307 }
308 }
309 _ = tokio::time::sleep(Duration::from_millis(20)), if cancel_seen => {
310 if let Some(deadline) = cancel_abort_deadline {
311 if Instant::now() >= deadline {
312 join_set.abort_all();
313 }
314 }
315 }
316 }
317 }
318
319 let snapshot = read_snapshot(rt).await?;
320 Ok(RunTickResult {
321 actions: applied,
322 snapshot,
323 })
324}
325
326pub async fn run_loop<H: WorkflowExecutionHooks + Clone + Send + 'static>(
327 rt: &mut WorkflowRuntimeContext,
328 hooks: &mut H,
329 max_ticks: usize,
330 max_concurrency: usize,
331) -> Result<RunLoopResult> {
332 let mut ticks = 0usize;
333 loop {
334 if ticks >= max_ticks {
335 let snapshot = read_snapshot(rt).await?;
336 return Ok(RunLoopResult {
337 reason: RunLoopStopReason::MaxTicks,
338 ticks,
339 last_snapshot: snapshot,
340 });
341 }
342
343 check_pending_cancels(rt, hooks).await?;
344
345 let pre_recovery_snapshot = read_snapshot(rt).await?;
352 if matches!(
353 pre_recovery_snapshot.run.status,
354 RunStatus::Succeeded | RunStatus::Failed | RunStatus::Cancelled
355 ) {
356 return Ok(RunLoopResult {
357 reason: RunLoopStopReason::Terminal,
358 ticks,
359 last_snapshot: pre_recovery_snapshot,
360 });
361 }
362
363 if !pre_recovery_snapshot.dangling.effect_attempted.is_empty() {
364 let recovery = hooks
365 .recover_dangling_effects(&mut rt.log, &pre_recovery_snapshot)
366 .await?;
367 if recovery.had_progress {
368 continue;
371 }
372 }
377
378 if !pre_recovery_snapshot.dangling.wait_resolutions.is_empty() {
384 let had_progress = resolve_wait_terminals(rt, &pre_recovery_snapshot).await?;
385 if had_progress {
386 continue;
387 }
388 }
389
390 let tick = run_tick(rt, hooks, max_concurrency).await?;
391 ticks += 1;
392 if tick.snapshot.run.status == RunStatus::Succeeded
393 || tick.snapshot.run.status == RunStatus::Failed
394 || tick.snapshot.run.status == RunStatus::Cancelled
395 {
396 return Ok(RunLoopResult {
397 reason: RunLoopStopReason::Terminal,
398 ticks,
399 last_snapshot: tick.snapshot,
400 });
401 }
402 if tick.actions == 0 {
403 let has_waits = !tick.snapshot.dangling.waits.is_empty()
404 && tick
405 .snapshot
406 .dangling
407 .waits
408 .iter()
409 .any(|w| !tick.snapshot.dangling.cancels.contains(w));
410 let reason = if has_waits {
411 RunLoopStopReason::AwaitingWait
412 } else {
413 RunLoopStopReason::NoProgress
414 };
415 return Ok(RunLoopResult {
416 reason,
417 ticks,
418 last_snapshot: tick.snapshot,
419 });
420 }
421 }
422}
423
424async fn check_pending_cancels<H: WorkflowExecutionHooks + Send>(
425 rt: &mut WorkflowRuntimeContext,
426 hooks: &mut H,
427) -> Result<()> {
428 let _events = rt.log.read_all()?;
429 let snapshot = read_snapshot(rt).await?;
430 let mut cancelled_activities: Vec<String> = Vec::new();
431 let mut cancelled_nodes: Vec<String> = Vec::new();
432
433 for activity_id in &snapshot.dangling.cancels {
434 cancelled_activities.push(activity_id.clone());
435 let attempt_id = snapshot
436 .activities
437 .iter()
438 .find(|a| &a.activity_id == activity_id)
439 .and_then(|a| a.current_attempt_id.clone())
440 .unwrap_or_else(|| format!("{}-attempt-1", activity_id));
441 let origin = snapshot
442 .run
443 .cancelled_run_intent
444 .as_ref()
445 .map(|i| i.cancel_origin_event_id.clone())
446 .or_else(|| {
447 snapshot
448 .run
449 .cancelled_node_intents
450 .values()
451 .next()
452 .map(|i| i.cancel_origin_event_id.clone())
453 })
454 .unwrap_or_default();
455 let _ = crate::complete_activity_cancel(
456 &mut rt.log,
457 crate::CompleteActivityCancelInput {
458 activity_id: activity_id.clone(),
459 attempt_id,
460 cancel_origin_event_id: origin,
461 },
462 WorkflowActor::Scheduler,
463 )
464 .await;
465 }
466
467 if let Some(ref intent) = snapshot.run.cancelled_run_intent {
468 if snapshot.run.status != RunStatus::Cancelled {
469 let _ = crate::complete_run_cancel(
470 &mut rt.log,
471 crate::CompleteRunCancelInput {
472 cancel_origin_event_id: intent.cancel_origin_event_id.clone(),
473 },
474 WorkflowActor::Scheduler,
475 )
476 .await;
477 }
478 }
479
480 if !snapshot.run.cancelled_node_intents.is_empty() {
481 for (node_id, intent) in &snapshot.run.cancelled_node_intents {
482 cancelled_nodes.push(node_id.clone());
483 let _ = crate::complete_node_cancel(
484 &mut rt.log,
485 crate::CompleteNodeCancelInput {
486 node_id: node_id.clone(),
487 cancel_origin_event_id: intent.cancel_origin_event_id.clone(),
488 },
489 WorkflowActor::Scheduler,
490 )
491 .await;
492 }
493 }
494
495 if !cancelled_activities.is_empty() || !cancelled_nodes.is_empty() {
497 hooks
498 .on_activities_cancelled(&cancelled_activities, &cancelled_nodes, &rt.log.run_id)
499 .await;
500 }
501
502 Ok(())
503}
504
505fn snapshot_has_pending_cancel(snapshot: &RunSnapshotDTO) -> bool {
506 snapshot.run.cancelled_run_intent.is_some() || !snapshot.run.cancelled_node_intents.is_empty()
507}
508
509fn select_tick_actions(
510 actions: Vec<OrchestratorAction>,
511 def: &WorkflowDefinition,
512 max_concurrency: usize,
513) -> Vec<ScheduledAction> {
514 let limit = max_concurrency.max(1);
515 let mut selected = Vec::new();
516 let mut seen = std::collections::HashSet::new();
517 let mut dispatch_count: usize = 0;
518 for action in actions {
519 let serialization_key = action_serialization_key(def, &action);
520 if seen.insert(serialization_key.clone()) {
521 let is_dispatch = action.is_dispatch();
522 if !is_dispatch || dispatch_count < limit {
527 if is_dispatch {
528 dispatch_count += 1;
529 }
530 selected.push(ScheduledAction { action });
531 }
532 }
533 }
534 selected
535}
536
537fn action_serialization_key(_def: &WorkflowDefinition, action: &OrchestratorAction) -> String {
538 match action {
539 OrchestratorAction::DispatchWork { node_id, node, .. } => {
540 let bot_key = match node {
541 WorkflowNode::Subagent(node) => Some(format!("bot:{}", node.bot)),
542 WorkflowNode::HostExecutor(node) => Some(format!("executor:{}", node.executor)),
543 WorkflowNode::Loop(_) | WorkflowNode::Decision(_) => None,
544 };
545 bot_key.unwrap_or_else(|| format!("node:{node_id}"))
546 }
547 OrchestratorAction::DispatchGate { node_id, .. } => format!("gate:{node_id}"),
548 OrchestratorAction::CompleteNodeSucceeded { node_id, .. }
549 | OrchestratorAction::CompleteNodeFailed { node_id, .. } => {
550 format!("node:{node_id}")
551 }
552 OrchestratorAction::CompleteRunSucceeded { sink_node_id, .. } => {
553 format!("run:{sink_node_id}:succeeded")
554 }
555 OrchestratorAction::CompleteRunFailed { failed_node_id } => {
556 format!("run:{failed_node_id}:failed")
557 }
558 OrchestratorAction::StartLoop { node_id, .. } => {
559 format!("loop:start:{node_id}")
560 }
561 OrchestratorAction::StartLoopIteration { node_id, .. } => {
562 format!("loop:iter-start:{node_id}")
563 }
564 OrchestratorAction::FinishLoopIteration { node_id, .. } => {
565 format!("loop:iter-finish:{node_id}")
566 }
567 OrchestratorAction::FinishLoop { node_id, .. } => {
568 format!("loop:finish:{node_id}")
569 }
570 }
571}
572
573pub async fn dispatch_gate(
574 rt: &mut WorkflowRuntimeContext,
575 action: &crate::OrchestratorAction,
576) -> Result<()> {
577 match action {
578 OrchestratorAction::DispatchGate {
579 node_id,
580 activity_id,
581 human_gate,
582 } => {
583 let attempt_id = gate_attempt_id(activity_id);
584 let input_ref = write_json_blob(
585 &mut rt.log,
586 serde_json::json!({
587 "kind": "human-gate",
588 "prompt": human_gate.prompt,
589 "approvers": human_gate.approvers,
590 }),
591 )?;
592 rt.log.append(EventDraft {
593 event_type: "attemptCreated".to_string(),
594 actor: WorkflowActor::Scheduler,
595 payload: serde_json::json!({
596 "nodeId": node_id,
597 "activityId": activity_id,
598 "attemptId": attempt_id,
599 "attemptNumber": 1,
600 "inputRef": input_ref,
601 }),
602 timestamp: None,
603 payload_hash: None,
604 })?;
605
606 let snap = read_snapshot(rt).await?;
607 let ctx = BindingContext {
608 snapshot: &snap,
609 def: &rt.def,
610 run_dir: &rt.log.run_dir,
611 loop_context: loop_context_from_activity(activity_id),
612 };
613 let resolved_prompt = resolve_bound_string(&human_gate.prompt, &ctx).await?;
614 let prompt_field = split_prompt(&mut rt.log, &resolved_prompt)?;
615 let _ = crate::create_wait(
616 &mut rt.log,
617 crate::CreateWaitInput {
618 activity_id: activity_id.clone(),
619 attempt_id,
620 node_id: node_id.clone(),
621 wait_kind: crate::WaitKind::HumanGate,
622 deadline_at: human_gate.deadline_ms.map(|ms| now_ms() + ms),
623 prompt: prompt_field.prompt,
624 prompt_ref: prompt_field.prompt_ref,
625 prompt_preview: prompt_field.prompt_preview,
626 approvers: human_gate.approvers.clone(),
627 on_timeout: human_gate.on_timeout.as_deref().map(|v| match v {
628 "success" => crate::WaitOnTimeout::Success,
629 _ => crate::WaitOnTimeout::Fail,
630 }),
631 },
632 )
633 .await?;
634 Ok(())
635 }
636 _ => anyhow::bail!("dispatch_gate called with non-gate action"),
637 }
638}
639
640pub async fn dispatch_work<H: WorkflowExecutionHooks>(
641 rt: &mut WorkflowRuntimeContext,
642 hooks: &mut H,
643 action: &crate::OrchestratorAction,
644) -> Result<WorkflowDispatchOutcome> {
645 match action {
646 OrchestratorAction::DispatchWork {
647 node_id,
648 activity_id,
649 node,
650 } => {
651 let attempt_id = work_attempt_id(activity_id, 1);
652 let input_ref = write_json_blob(
653 &mut rt.log,
654 serde_json::json!({
655 "kind": match node {
656 WorkflowNode::Subagent(_) => "subagent",
657 WorkflowNode::HostExecutor(_) => "hostExecutor",
658 WorkflowNode::Loop(_) => "loop",
659 WorkflowNode::Decision(_) => "decision",
660 },
661 "bot_or_executor": match node {
662 WorkflowNode::Subagent(n) => Value::String(n.bot.clone()),
663 WorkflowNode::HostExecutor(n) => Value::String(n.executor.clone()),
664 _ => Value::Null,
665 },
666 "prompt_or_input": match node {
667 WorkflowNode::Subagent(n) => n.prompt.clone(),
668 WorkflowNode::HostExecutor(n) => n.input.clone(),
669 _ => Value::Null,
670 }
671 }),
672 )?;
673 rt.log.append(EventDraft {
674 event_type: "attemptCreated".to_string(),
675 actor: WorkflowActor::Scheduler,
676 payload: serde_json::json!({
677 "nodeId": node_id,
678 "activityId": activity_id,
679 "attemptId": attempt_id,
680 "attemptNumber": 1,
681 "inputRef": input_ref,
682 }),
683 timestamp: None,
684 payload_hash: None,
685 })?;
686
687 let snap = read_snapshot(rt).await?;
688 let bind_ctx = BindingContext {
689 snapshot: &snap,
690 def: &rt.def,
691 run_dir: &rt.log.run_dir,
692 loop_context: loop_context_from_activity(activity_id),
693 };
694
695 match node {
696 WorkflowNode::Subagent(subagent) => {
697 let resolved_prompt = resolve_bound_string(&subagent.prompt, &bind_ctx).await?;
698 rt.log.append(EventDraft {
699 event_type: "activityRunning".to_string(),
700 actor: WorkflowActor::Scheduler,
701 payload: serde_json::json!({
702 "activityId": activity_id,
703 "attemptId": attempt_id,
704 "leaseId": format!("lease-{}", attempt_id),
705 }),
706 timestamp: None,
707 payload_hash: None,
708 })?;
709 let result = hooks
710 .execute_subagent(
711 WorkflowDispatchRun {
712 run_id: &rt.log.run_id,
713 workflow_id: snap.run.workflow_id.as_deref().unwrap_or(""),
714 revision_id: snap.run.revision_id.as_deref().unwrap_or(""),
715 activity_id,
716 attempt_id: &attempt_id,
717 node_id,
718 },
719 subagent,
720 resolved_prompt,
721 )
722 .await?;
723 settle_work_result(&mut rt.log, activity_id, &attempt_id, result).await
724 }
725 WorkflowNode::HostExecutor(executor) => {
726 let resolved_input = resolve_bindings(&executor.input, &bind_ctx).await?;
727
728 let prepared = hooks
730 .prepare_host_executor(&executor.executor, &resolved_input)
731 .context("prepare_host_executor failed")?;
732
733 let _ = write_effect_input_sidecar(
735 &rt.log,
736 activity_id,
737 &attempt_id,
738 &prepared.canonical_input,
739 )
740 .await?;
741
742 let idempotency_key = derive_workflow_idempotency_key(
744 snap.run.workflow_id.as_deref().unwrap_or(""),
745 snap.run.revision_id.as_deref().unwrap_or(""),
746 &rt.log.run_id,
747 node_id,
748 &attempt_id,
749 );
750 let input_bytes = serde_json::to_vec(&prepared.canonical_input)?;
751 let input_hash = sha256_hex(&input_bytes);
752 rt.log.append(EventDraft {
753 event_type: "effectAttempted".to_string(),
754 actor: WorkflowActor::Scheduler,
755 payload: serde_json::json!({
756 "activityId": activity_id,
757 "attemptId": attempt_id,
758 "idempotencyKey": idempotency_key,
759 "inputHash": input_hash,
760 "idempotencyTtlMs": prepared.idempotency_ttl_ms,
761 "provider": prepared.provider,
762 }),
763 timestamp: None,
764 payload_hash: None,
765 })?;
766
767 let result = hooks
768 .execute_host_executor(
769 WorkflowDispatchRun {
770 run_id: &rt.log.run_id,
771 workflow_id: snap.run.workflow_id.as_deref().unwrap_or(""),
772 revision_id: snap.run.revision_id.as_deref().unwrap_or(""),
773 activity_id,
774 attempt_id: &attempt_id,
775 node_id,
776 },
777 executor,
778 prepared.parsed_input,
779 )
780 .await?;
781 settle_work_result(&mut rt.log, activity_id, &attempt_id, result).await
782 }
783 WorkflowNode::Loop(_) | WorkflowNode::Decision(_) => {
784 anyhow::bail!("dispatch_work received unsupported node type")
785 }
786 }
787 }
788 _ => anyhow::bail!("dispatch_work called with non-work action"),
789 }
790}
791
792pub async fn complete_node_succeeded(
793 log: &mut EventLog,
794 action: &crate::OrchestratorAction,
795) -> Result<()> {
796 if let OrchestratorAction::CompleteNodeSucceeded {
797 node_id,
798 last_activity_id,
799 ..
800 } = action
801 {
802 let _ = log.append(EventDraft {
803 event_type: "nodeSucceeded".to_string(),
804 actor: WorkflowActor::Scheduler,
805 payload: serde_json::json!({
806 "nodeId": node_id,
807 "lastActivityId": last_activity_id,
808 }),
809 timestamp: None,
810 payload_hash: None,
811 })?;
812 Ok(())
813 } else {
814 anyhow::bail!("complete_node_succeeded called with wrong action")
815 }
816}
817
818pub async fn complete_node_failed(
819 log: &mut EventLog,
820 action: &crate::OrchestratorAction,
821) -> Result<()> {
822 if let OrchestratorAction::CompleteNodeFailed {
823 node_id,
824 last_activity_id,
825 error_class,
826 } = action
827 {
828 let _ = log.append(EventDraft {
829 event_type: "nodeFailed".to_string(),
830 actor: WorkflowActor::Scheduler,
831 payload: serde_json::json!({
832 "nodeId": node_id,
833 "lastActivityId": last_activity_id,
834 "errorClass": error_class,
835 }),
836 timestamp: None,
837 payload_hash: None,
838 })?;
839 Ok(())
840 } else {
841 anyhow::bail!("complete_node_failed called with wrong action")
842 }
843}
844
845pub async fn complete_run_succeeded(
846 log: &mut EventLog,
847 action: &crate::OrchestratorAction,
848) -> Result<()> {
849 if let OrchestratorAction::CompleteRunSucceeded { output_ref, .. } = action {
850 let _ = log.append(EventDraft {
851 event_type: "runSucceeded".to_string(),
852 actor: WorkflowActor::Scheduler,
853 payload: serde_json::json!({
854 "outputRef": output_ref,
855 }),
856 timestamp: None,
857 payload_hash: None,
858 })?;
859 Ok(())
860 } else {
861 anyhow::bail!("complete_run_succeeded called with wrong action")
862 }
863}
864
865pub async fn complete_run_failed(
866 log: &mut EventLog,
867 action: &crate::OrchestratorAction,
868) -> Result<()> {
869 if let OrchestratorAction::CompleteRunFailed { failed_node_id } = action {
870 let root_cause_event_id = find_root_cause_event_id(log, failed_node_id).await?;
871 let _ = log.append(EventDraft {
872 event_type: "runFailed".to_string(),
873 actor: WorkflowActor::Scheduler,
874 payload: serde_json::json!({
875 "failedNodeId": failed_node_id,
876 "rootCauseEventId": root_cause_event_id,
877 }),
878 timestamp: None,
879 payload_hash: None,
880 })?;
881 Ok(())
882 } else {
883 anyhow::bail!("complete_run_failed called with wrong action")
884 }
885}
886
887pub async fn start_loop(log: &mut EventLog, action: &crate::OrchestratorAction) -> Result<()> {
888 if let OrchestratorAction::StartLoop {
889 node_id,
890 max_iterations,
891 } = action
892 {
893 let _ = log.append(EventDraft {
894 event_type: "loopStarted".to_string(),
895 actor: WorkflowActor::Scheduler,
896 payload: serde_json::json!({
897 "loopId": node_id,
898 "maxIterations": max_iterations,
899 }),
900 timestamp: None,
901 payload_hash: None,
902 })?;
903 Ok(())
904 } else {
905 anyhow::bail!("start_loop called with wrong action")
906 }
907}
908
909pub async fn start_loop_iteration(
910 log: &mut EventLog,
911 action: &crate::OrchestratorAction,
912) -> Result<()> {
913 if let OrchestratorAction::StartLoopIteration { node_id, iteration } = action {
914 let _ = log.append(EventDraft {
915 event_type: "loopIterationStarted".to_string(),
916 actor: WorkflowActor::Scheduler,
917 payload: serde_json::json!({
918 "loopId": node_id,
919 "iteration": iteration,
920 }),
921 timestamp: None,
922 payload_hash: None,
923 })?;
924 Ok(())
925 } else {
926 anyhow::bail!("start_loop_iteration called with wrong action")
927 }
928}
929
930pub async fn finish_loop_iteration(
931 log: &mut EventLog,
932 action: &crate::OrchestratorAction,
933) -> Result<()> {
934 if let OrchestratorAction::FinishLoopIteration {
935 node_id,
936 iteration,
937 resolution,
938 decision_activity_id,
939 wait_resolved_event_id,
940 by,
941 comment,
942 timed_out,
943 } = action
944 {
945 let _ = log.append(EventDraft {
946 event_type: "loopIterationFinished".to_string(),
947 actor: WorkflowActor::Scheduler,
948 payload: serde_json::json!({
949 "loopId": node_id,
950 "iteration": iteration,
951 "resolution": resolution,
952 "decisionActivityId": decision_activity_id,
953 "waitResolvedEventId": wait_resolved_event_id,
954 "by": by,
955 "comment": comment,
956 "timedOut": timed_out,
957 }),
958 timestamp: None,
959 payload_hash: None,
960 })?;
961 Ok(())
962 } else {
963 anyhow::bail!("finish_loop_iteration called with wrong action")
964 }
965}
966
967pub async fn finish_loop(log: &mut EventLog, action: &crate::OrchestratorAction) -> Result<()> {
968 if let OrchestratorAction::FinishLoop {
969 node_id,
970 final_iteration,
971 resolution,
972 output_ref,
973 error_code,
974 error_class,
975 } = action
976 {
977 let _ = log.append(EventDraft {
978 event_type: "loopFinished".to_string(),
979 actor: WorkflowActor::Scheduler,
980 payload: serde_json::json!({
981 "loopId": node_id,
982 "finalIteration": final_iteration,
983 "resolution": resolution,
984 "outputRef": output_ref,
985 "errorCode": error_code,
986 "errorClass": error_class,
987 }),
988 timestamp: None,
989 payload_hash: None,
990 })?;
991 Ok(())
992 } else {
993 anyhow::bail!("finish_loop called with wrong action")
994 }
995}
996
997async fn apply_orchestrator_action<H: WorkflowExecutionHooks>(
998 rt: &mut WorkflowRuntimeContext,
999 hooks: &mut H,
1000 action: OrchestratorAction,
1001) -> Result<()> {
1002 match action {
1003 OrchestratorAction::DispatchGate { .. } => dispatch_gate(rt, &action).await?,
1004 OrchestratorAction::DispatchWork { .. } => {
1005 let _ = dispatch_work(rt, hooks, &action).await?;
1006 }
1007 OrchestratorAction::CompleteNodeSucceeded { .. } => {
1008 complete_node_succeeded(&mut rt.log, &action).await?
1009 }
1010 OrchestratorAction::CompleteNodeFailed { .. } => {
1011 complete_node_failed(&mut rt.log, &action).await?
1012 }
1013 OrchestratorAction::CompleteRunSucceeded { .. } => {
1014 complete_run_succeeded(&mut rt.log, &action).await?
1015 }
1016 OrchestratorAction::CompleteRunFailed { .. } => {
1017 complete_run_failed(&mut rt.log, &action).await?
1018 }
1019 OrchestratorAction::StartLoop { .. } => start_loop(&mut rt.log, &action).await?,
1020 OrchestratorAction::StartLoopIteration { .. } => {
1021 start_loop_iteration(&mut rt.log, &action).await?
1022 }
1023 OrchestratorAction::FinishLoopIteration { .. } => {
1024 finish_loop_iteration(&mut rt.log, &action).await?
1025 }
1026 OrchestratorAction::FinishLoop { .. } => finish_loop(&mut rt.log, &action).await?,
1027 }
1028 Ok(())
1029}
1030
1031async fn settle_work_result(
1032 log: &mut EventLog,
1033 activity_id: &str,
1034 attempt_id: &str,
1035 result: WorkflowDispatchOutcome,
1036) -> Result<WorkflowDispatchOutcome> {
1037 match &result {
1038 WorkflowDispatchOutcome::Succeeded { output, .. } => {
1039 let output_ref = write_json_blob(log, output.clone())?;
1040 let _ = log.append(EventDraft {
1041 event_type: "activitySucceeded".to_string(),
1042 actor: WorkflowActor::Worker,
1043 payload: serde_json::json!({
1044 "activityId": activity_id,
1045 "attemptId": attempt_id,
1046 "outputRef": output_ref,
1047 }),
1048 timestamp: None,
1049 payload_hash: None,
1050 })?;
1051 }
1052 WorkflowDispatchOutcome::Failed {
1053 error_code,
1054 error_class,
1055 error_message,
1056 ..
1057 } => {
1058 let _ = log.append(EventDraft {
1059 event_type: "activityFailed".to_string(),
1060 actor: WorkflowActor::Worker,
1061 payload: serde_json::json!({
1062 "activityId": activity_id,
1063 "attemptId": attempt_id,
1064 "error": {
1065 "errorCode": error_code,
1066 "errorClass": error_class,
1067 "errorMessage": error_message,
1068 }
1069 }),
1070 timestamp: None,
1071 payload_hash: None,
1072 })?;
1073 }
1074 WorkflowDispatchOutcome::Cancelled {
1075 cancel_origin_event_id,
1076 ..
1077 } => {
1078 let _ = log.append(EventDraft {
1079 event_type: "activityCanceled".to_string(),
1080 actor: WorkflowActor::Worker,
1081 payload: serde_json::json!({
1082 "activityId": activity_id,
1083 "attemptId": attempt_id,
1084 "cancelOriginEventId": cancel_origin_event_id,
1085 }),
1086 timestamp: None,
1087 payload_hash: None,
1088 })?;
1089 }
1090 }
1091 Ok(result)
1092}
1093
1094async fn find_root_cause_event_id(log: &EventLog, node_id: &str) -> Result<String> {
1095 let events = log.read_all()?;
1096 let mut node_failed_event_id: Option<String> = None;
1097 let mut activity_failed_event_id: Option<String> = None;
1098 let mut loop_finished_event_id: Option<String> = None;
1099 let mut node_activities = std::collections::BTreeSet::new();
1100 for e in &events {
1101 match e.event_type.as_str() {
1102 "attemptCreated" => {
1103 if e.payload.get("nodeId").and_then(Value::as_str) == Some(node_id)
1104 && let Some(activity_id) = e.payload.get("activityId").and_then(Value::as_str)
1105 {
1106 node_activities.insert(activity_id.to_string());
1107 }
1108 }
1109 "activityFailed" => {
1110 if let Some(activity_id) = e.payload.get("activityId").and_then(Value::as_str)
1111 && node_activities.contains(activity_id)
1112 {
1113 activity_failed_event_id = Some(e.event_id.clone());
1114 }
1115 }
1116 "nodeFailed" => {
1117 if e.payload.get("nodeId").and_then(Value::as_str) == Some(node_id) {
1118 node_failed_event_id = Some(e.event_id.clone());
1119 }
1120 }
1121 "loopFinished" => {
1122 if e.payload.get("loopId").and_then(Value::as_str) == Some(node_id)
1123 && e.payload.get("resolution").and_then(Value::as_str) != Some("approved")
1124 {
1125 loop_finished_event_id = Some(e.event_id.clone());
1126 }
1127 }
1128 _ => {}
1129 }
1130 }
1131 Ok(activity_failed_event_id
1132 .or(node_failed_event_id)
1133 .or(loop_finished_event_id)
1134 .unwrap_or_else(|| {
1135 events
1136 .first()
1137 .map(|e| e.event_id.clone())
1138 .unwrap_or_default()
1139 }))
1140}
1141
1142fn gate_attempt_id(activity_id: &str) -> String {
1143 format!("{activity_id}::att-1")
1144}
1145
1146fn work_attempt_id(activity_id: &str, attempt_number: u64) -> String {
1147 format!("{activity_id}::att-{attempt_number}")
1148}
1149
1150fn now_ms() -> u64 {
1151 std::time::SystemTime::now()
1152 .duration_since(std::time::UNIX_EPOCH)
1153 .unwrap_or_default()
1154 .as_millis() as u64
1155}
1156
1157fn split_prompt(log: &mut EventLog, resolved_prompt: &str) -> Result<PromptField> {
1158 if resolved_prompt.len() <= 1024 {
1159 return Ok(PromptField {
1160 prompt: Some(resolved_prompt.to_string()),
1161 prompt_ref: None,
1162 prompt_preview: None,
1163 });
1164 }
1165 let prompt_ref = write_json_blob(log, Value::String(resolved_prompt.to_string()))?;
1166 Ok(PromptField {
1167 prompt: None,
1168 prompt_ref: Some(prompt_ref),
1169 prompt_preview: Some(make_prompt_preview(resolved_prompt)),
1170 })
1171}
1172
1173#[derive(Debug)]
1174struct PromptField {
1175 prompt: Option<String>,
1176 prompt_ref: Option<WorkflowOutputRef>,
1177 prompt_preview: Option<String>,
1178}
1179
1180fn make_prompt_preview(full: &str) -> String {
1181 const MAX: usize = 480;
1182 if full.chars().count() <= MAX {
1183 return full.to_string();
1184 }
1185 let suffix = "…(完整内容见 dashboard)";
1186 let budget = MAX.saturating_sub(suffix.chars().count());
1187 let mut out = String::new();
1188 for ch in full.chars().take(budget) {
1189 out.push(ch);
1190 }
1191 out.push_str(suffix);
1192 out
1193}
1194
1195fn write_json_blob(log: &mut EventLog, value: Value) -> Result<WorkflowOutputRef> {
1196 let bytes = serde_json::to_vec(&value)?;
1197 let hash = sha256_hex(&bytes);
1198 let path = PathBuf::from(&log.blob_dir).join(&hash);
1199 fs::write(&path, &bytes)?;
1200 Ok(WorkflowOutputRef {
1201 output_hash: format!("sha256:{hash}"),
1202 output_path: path.display().to_string(),
1203 output_bytes: bytes.len(),
1204 output_schema_version: 1,
1205 content_type: Some("application/json".to_string()),
1206 })
1207}
1208
1209fn sha256_hex(bytes: &[u8]) -> String {
1210 let mut hasher = Sha256::new();
1211 hasher.update(bytes);
1212 format!("{:x}", hasher.finalize())
1213}
1214
1215pub fn derive_workflow_idempotency_key(
1222 workflow_id: &str,
1223 revision_id: &str,
1224 run_id: &str,
1225 node_id: &str,
1226 attempt_id: &str,
1227) -> String {
1228 let seed = serde_json::json!({
1229 "attemptId": attempt_id,
1230 "nodeId": node_id,
1231 "revisionId": revision_id,
1232 "runId": run_id,
1233 "workflowId": workflow_id,
1234 });
1235 let mut hasher = Sha256::new();
1236 let canonical = serde_json::to_vec(&seed).expect("workflow idempotency seed serializable");
1237 hasher.update(&canonical);
1238 let hash = format!("{:x}", hasher.finalize());
1239 let namespace = "wf_";
1240 let max_len = 50usize;
1241 let hash_len = max_len.saturating_sub(namespace.len());
1242 format!("{namespace}{}", &hash[..hash_len.min(hash.len())])
1243}
1244
1245pub fn get_host_executor_provider_meta(executor_name: &str) -> (&'static str, u64) {
1251 match executor_name {
1252 "feishu-send" | "feishu-reply" => ("feishu-im", 60_000),
1253 "beam-schedule" => ("beam-schedule", 86_400_000),
1254 _ => ("manual", 300_000),
1255 }
1256}
1257
1258fn loop_context_from_activity(activity_id: &str) -> Option<LoopContext<'_>> {
1259 let loop_start = activity_id.find("::loop::")?;
1260 let after_loop = &activity_id[loop_start + "::loop::".len()..];
1261 let iter_end = after_loop.find("::")?;
1262 let loop_part = &after_loop[..iter_end];
1263 let (loop_id, iteration) = loop_part.rsplit_once('.')?;
1264 let iteration = iteration.parse().ok()?;
1265 Some(LoopContext { loop_id, iteration })
1266}
1267
1268async fn resolve_wait_terminals(
1276 rt: &mut WorkflowRuntimeContext,
1277 snapshot: &RunSnapshotDTO,
1278) -> Result<bool> {
1279 let mut had_progress = false;
1280 for activity_id in &snapshot.dangling.wait_resolutions {
1281 let Some(activity) = snapshot
1282 .activities
1283 .iter()
1284 .find(|a| &a.activity_id == activity_id)
1285 else {
1286 continue;
1287 };
1288 let Some(latest) = activity.attempts.last() else {
1289 continue;
1290 };
1291 let Some(wait) = latest.wait.as_ref() else {
1292 continue;
1293 };
1294 let Some(resolution) = wait.resolution.as_ref() else {
1295 continue;
1296 };
1297 let attempt_id = &latest.attempt_id;
1298
1299 match resolution.kind.as_str() {
1300 "resolved" => {
1301 if matches!(resolution.resolution.as_deref(), Some("rejected")) {
1302 rt.log.append(EventDraft {
1304 event_type: "activityFailed".to_string(),
1305 actor: WorkflowActor::Scheduler,
1306 payload: serde_json::json!({
1307 "activityId": activity_id,
1308 "attemptId": attempt_id,
1309 "error": {
1310 "errorCode": "InputValidationFailed",
1311 "errorClass": "userFault",
1312 "errorMessage": format!(
1313 "Recovered wait terminal: rejected by {}{}",
1314 resolution.by.clone().unwrap_or_default(),
1315 resolution.comment.as_ref()
1316 .map(|c| format!(": {}", c))
1317 .unwrap_or_default()
1318 ),
1319 }
1320 }),
1321 timestamp: None,
1322 payload_hash: None,
1323 })?;
1324 had_progress = true;
1325 } else {
1326 let external_refs = serde_json::json!({
1328 "resolution": resolution.resolution,
1329 "by": resolution.by,
1330 "comment": resolution.comment,
1331 });
1332 let output_ref = write_json_blob(&mut rt.log, external_refs.clone())?;
1333 rt.log.append(EventDraft {
1334 event_type: "activitySucceeded".to_string(),
1335 actor: WorkflowActor::Scheduler,
1336 payload: serde_json::json!({
1337 "activityId": activity_id,
1338 "attemptId": attempt_id,
1339 "outputRef": output_ref,
1340 "externalRefs": external_refs,
1341 }),
1342 timestamp: None,
1343 payload_hash: None,
1344 })?;
1345 had_progress = true;
1346 }
1347 }
1348 "deadlineExceeded" => {
1349 if matches!(wait.on_timeout.as_deref(), Some("success")) {
1350 let external_refs = serde_json::json!({
1351 "defaultedToTimeout": true,
1352 "deadlineAt": resolution.deadline_at,
1353 });
1354 let output_ref = write_json_blob(&mut rt.log, external_refs.clone())?;
1355 rt.log.append(EventDraft {
1356 event_type: "activitySucceeded".to_string(),
1357 actor: WorkflowActor::Scheduler,
1358 payload: serde_json::json!({
1359 "activityId": activity_id,
1360 "attemptId": attempt_id,
1361 "outputRef": output_ref,
1362 "externalRefs": external_refs,
1363 }),
1364 timestamp: None,
1365 payload_hash: None,
1366 })?;
1367 had_progress = true;
1368 } else {
1369 rt.log.append(EventDraft {
1371 event_type: "activityFailed".to_string(),
1372 actor: WorkflowActor::Scheduler,
1373 payload: serde_json::json!({
1374 "activityId": activity_id,
1375 "attemptId": attempt_id,
1376 "error": {
1377 "errorCode": "WaitDeadlineExceeded",
1378 "errorClass": "userFault",
1379 "errorMessage": "Recovered wait terminal: deadline exceeded",
1380 }
1381 }),
1382 timestamp: None,
1383 payload_hash: None,
1384 })?;
1385 had_progress = true;
1386 }
1387 }
1388 _ => {}
1389 }
1390 }
1391 Ok(had_progress)
1392}
1393
1394async fn read_snapshot(rt: &WorkflowRuntimeContext) -> Result<RunSnapshotDTO> {
1395 read_run_snapshot(&rt.log.run_dir)
1396 .await?
1397 .context("workflow runtime requires an existing run snapshot")
1398}
1399
1400#[cfg(test)]
1401mod tests {
1402 use super::*;
1403 use crate::DanglingSnapshot;
1404 use crate::LoopIterationStatus;
1405 use crate::LoopStatus;
1406 use crate::RunChatBinding;
1407 use crate::RunState;
1408 use crate::workflow_definition::NodeBase;
1409 use crate::workflow_definition::{DecisionNode, HumanGate, LoopNode, LoopOutputProjection, LoopTerminate};
1410 use crate::workflow_snapshot::NodeStatus;
1411 use std::collections::{BTreeMap, HashMap};
1412 use std::sync::Arc;
1413 use tokio::sync::Mutex;
1414
1415 #[derive(Clone)]
1416 struct FakeHooks;
1417
1418 #[async_trait]
1419 impl WorkflowExecutionHooks for FakeHooks {
1420 async fn execute_subagent(
1421 &mut self,
1422 _ctx: WorkflowDispatchRun<'_>,
1423 _node: &SubagentNode,
1424 resolved_prompt: String,
1425 ) -> Result<WorkflowDispatchOutcome> {
1426 Ok(WorkflowDispatchOutcome::Succeeded {
1427 output: Value::String(resolved_prompt),
1428 session: None,
1429 })
1430 }
1431
1432 async fn execute_host_executor(
1433 &mut self,
1434 _ctx: WorkflowDispatchRun<'_>,
1435 _node: &HostExecutorNode,
1436 resolved_input: Value,
1437 ) -> Result<WorkflowDispatchOutcome> {
1438 Ok(WorkflowDispatchOutcome::Succeeded {
1439 output: resolved_input,
1440 session: None,
1441 })
1442 }
1443 }
1444
1445 fn temp_run_dir(label: &str) -> PathBuf {
1446 std::env::temp_dir().join(format!(
1447 "beam-workflow-runtime-{label}-{}-{}",
1448 std::process::id(),
1449 std::time::SystemTime::now()
1450 .duration_since(std::time::UNIX_EPOCH)
1451 .unwrap_or_default()
1452 .as_nanos()
1453 ))
1454 }
1455
1456 fn workflow_def() -> WorkflowDefinition {
1457 WorkflowDefinition {
1458 workflow_id: "flow-a".to_string(),
1459 version: 1,
1460 params: None,
1461 defaults: None,
1462 nodes: BTreeMap::from([(
1463 "a".to_string(),
1464 WorkflowNode::Subagent(SubagentNode {
1465 base: NodeBase {
1466 description: None,
1467 depends: None,
1468 human_gate: None,
1469 retry_policy: None,
1470 timeout_ms: None,
1471 max_output_bytes: None,
1472 output_schema: None,
1473 unsafe_allow_ungated: None,
1474 },
1475 bot: "bot-a".to_string(),
1476 prompt: Value::String("hello ${params.name}".to_string()),
1477 working_dir: None,
1478 model_overrides: None,
1479 tool_policy: None,
1480 }),
1481 )]),
1482 }
1483 }
1484
1485 #[tokio::test]
1486 async fn run_tick_dispatches_simple_workflow() {
1487 let run_dir = temp_run_dir("tick");
1488 let _ = fs::remove_dir_all(&run_dir);
1489 fs::create_dir_all(run_dir.join("blobs")).unwrap();
1490 fs::write(run_dir.join("params.json"), r#"{"name":"beam"}"#).unwrap();
1491
1492 let paths = crate::BeamPaths::from_root(run_dir.clone());
1493 let params = BTreeMap::from([(String::from("name"), String::from("beam"))]);
1494 let run_id = "run-1";
1495 let bootstrap = crate::bootstrap_workflow_run(
1496 &paths,
1497 crate::BootstrapWorkflowRunInput {
1498 run_id,
1499 workflow_json: r#"{"workflowId":"flow-a","version":1,"nodes":{"a":{"type":"subagent","bot":"bot-a","prompt":"hello ${params.name}"}}}"#,
1500 expected_workflow_id: Some("flow-a"),
1501 params: ¶ms,
1502 initiator: "cli",
1503 chat_binding: Some(RunChatBinding {
1504 chat_id: "chat-1".to_string(),
1505 lark_app_id: "app-1".to_string(),
1506 }),
1507 },
1508 )
1509 .unwrap();
1510 let mut rt = WorkflowRuntimeContext {
1511 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
1512 def: workflow_def(),
1513 runs_base_dir: paths.workflow_runs_dir(),
1514 };
1515 let mut hooks = FakeHooks;
1516 let tick = run_tick(&mut rt, &mut hooks, 1).await.unwrap();
1517 assert!(tick.actions > 0);
1518 assert_eq!(tick.snapshot.run.workflow_id.as_deref(), Some("flow-a"));
1519 assert!(matches!(
1520 tick.snapshot
1521 .nodes
1522 .iter()
1523 .find(|node| node.node_id == "a")
1524 .map(|node| node.status),
1525 Some(NodeStatus::Succeeded | NodeStatus::Running | NodeStatus::Triggered)
1526 ));
1527 let _ = fs::remove_dir_all(&run_dir);
1528 let _ = bootstrap;
1529 }
1530
1531 #[tokio::test]
1532 async fn run_tick_honors_max_concurrency_cap() {
1533 let run_dir = temp_run_dir("cap");
1534 let _ = fs::remove_dir_all(&run_dir);
1535 fs::create_dir_all(run_dir.join("blobs")).unwrap();
1536 let paths = crate::BeamPaths::from_root(run_dir.clone());
1537 let params = BTreeMap::new();
1538 let run_id = "run-cap";
1539 crate::bootstrap_workflow_run(
1540 &paths,
1541 crate::BootstrapWorkflowRunInput {
1542 run_id,
1543 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"}}}"#,
1544 expected_workflow_id: Some("flow-cap"),
1545 params: ¶ms,
1546 initiator: "cli",
1547 chat_binding: Some(RunChatBinding {
1548 chat_id: "chat-1".to_string(),
1549 lark_app_id: "app-1".to_string(),
1550 }),
1551 },
1552 )
1553 .unwrap();
1554 let mut rt = WorkflowRuntimeContext {
1555 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
1556 def: WorkflowDefinition {
1557 workflow_id: "flow-cap".to_string(),
1558 version: 1,
1559 params: None,
1560 defaults: None,
1561 nodes: BTreeMap::from([
1562 (
1563 "a".to_string(),
1564 WorkflowNode::Subagent(SubagentNode {
1565 base: NodeBase {
1566 description: None,
1567 depends: None,
1568 human_gate: None,
1569 retry_policy: None,
1570 timeout_ms: None,
1571 max_output_bytes: None,
1572 output_schema: None,
1573 unsafe_allow_ungated: None,
1574 },
1575 bot: "bot-a".to_string(),
1576 prompt: Value::String("one".to_string()),
1577 working_dir: None,
1578 model_overrides: None,
1579 tool_policy: None,
1580 }),
1581 ),
1582 (
1583 "b".to_string(),
1584 WorkflowNode::Subagent(SubagentNode {
1585 base: NodeBase {
1586 description: None,
1587 depends: None,
1588 human_gate: None,
1589 retry_policy: None,
1590 timeout_ms: None,
1591 max_output_bytes: None,
1592 output_schema: None,
1593 unsafe_allow_ungated: None,
1594 },
1595 bot: "bot-b".to_string(),
1596 prompt: Value::String("two".to_string()),
1597 working_dir: None,
1598 model_overrides: None,
1599 tool_policy: None,
1600 }),
1601 ),
1602 ]),
1603 },
1604 runs_base_dir: paths.workflow_runs_dir(),
1605 };
1606 let mut hooks = FakeHooks;
1607 let tick = run_tick(&mut rt, &mut hooks, 1).await.unwrap();
1608 assert_eq!(tick.actions, 1);
1609 let snapshot = tick.snapshot;
1610 let attempted: Vec<_> = snapshot
1611 .activities
1612 .iter()
1613 .map(|a| a.activity_id.as_str())
1614 .collect();
1615 assert_eq!(attempted.len(), 1);
1616 let _ = fs::remove_dir_all(&run_dir);
1617 }
1618
1619 #[tokio::test]
1620 async fn run_tick_dispatches_multiple_actions_concurrently() {
1621 let run_dir = temp_run_dir("concurrent");
1622 let _ = fs::remove_dir_all(&run_dir);
1623 fs::create_dir_all(run_dir.join("blobs")).unwrap();
1624 let paths = crate::BeamPaths::from_root(run_dir.clone());
1625 let params = BTreeMap::new();
1626 let run_id = "run-concurrent";
1627 crate::bootstrap_workflow_run(
1628 &paths,
1629 crate::BootstrapWorkflowRunInput {
1630 run_id,
1631 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"}}}"#,
1632 expected_workflow_id: Some("flow-concurrent"),
1633 params: ¶ms,
1634 initiator: "cli",
1635 chat_binding: Some(RunChatBinding {
1636 chat_id: "chat-1".to_string(),
1637 lark_app_id: "app-1".to_string(),
1638 }),
1639 },
1640 )
1641 .unwrap();
1642 let mut rt = WorkflowRuntimeContext {
1643 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
1644 def: WorkflowDefinition {
1645 workflow_id: "flow-concurrent".to_string(),
1646 version: 1,
1647 params: None,
1648 defaults: None,
1649 nodes: BTreeMap::from([
1650 (
1651 "a".to_string(),
1652 WorkflowNode::Subagent(SubagentNode {
1653 base: NodeBase {
1654 description: None,
1655 depends: None,
1656 human_gate: None,
1657 retry_policy: None,
1658 timeout_ms: None,
1659 max_output_bytes: None,
1660 output_schema: None,
1661 unsafe_allow_ungated: None,
1662 },
1663 bot: "bot-a".to_string(),
1664 prompt: Value::String("one".to_string()),
1665 working_dir: None,
1666 model_overrides: None,
1667 tool_policy: None,
1668 }),
1669 ),
1670 (
1671 "b".to_string(),
1672 WorkflowNode::Subagent(SubagentNode {
1673 base: NodeBase {
1674 description: None,
1675 depends: None,
1676 human_gate: None,
1677 retry_policy: None,
1678 timeout_ms: None,
1679 max_output_bytes: None,
1680 output_schema: None,
1681 unsafe_allow_ungated: None,
1682 },
1683 bot: "bot-b".to_string(),
1684 prompt: Value::String("two".to_string()),
1685 working_dir: None,
1686 model_overrides: None,
1687 tool_policy: None,
1688 }),
1689 ),
1690 ]),
1691 },
1692 runs_base_dir: paths.workflow_runs_dir(),
1693 };
1694
1695 #[derive(Clone)]
1696 struct SleepyHooks;
1697
1698 #[async_trait]
1699 impl WorkflowExecutionHooks for SleepyHooks {
1700 async fn execute_subagent(
1701 &mut self,
1702 _ctx: WorkflowDispatchRun<'_>,
1703 _node: &SubagentNode,
1704 resolved_prompt: String,
1705 ) -> Result<WorkflowDispatchOutcome> {
1706 tokio::time::sleep(std::time::Duration::from_millis(120)).await;
1707 Ok(WorkflowDispatchOutcome::Succeeded {
1708 output: Value::String(resolved_prompt),
1709 session: None,
1710 })
1711 }
1712
1713 async fn execute_host_executor(
1714 &mut self,
1715 _ctx: WorkflowDispatchRun<'_>,
1716 _node: &HostExecutorNode,
1717 resolved_input: Value,
1718 ) -> Result<WorkflowDispatchOutcome> {
1719 Ok(WorkflowDispatchOutcome::Succeeded {
1720 output: resolved_input,
1721 session: None,
1722 })
1723 }
1724 }
1725
1726 let mut hooks = SleepyHooks;
1727 let started = std::time::Instant::now();
1728 let tick = run_tick(&mut rt, &mut hooks, 2).await.unwrap();
1729 let elapsed = started.elapsed();
1730 assert_eq!(tick.actions, 2);
1731 assert!(
1732 elapsed < std::time::Duration::from_millis(220),
1733 "run_tick took {:?}, expected concurrent execution under 220ms",
1734 elapsed
1735 );
1736 let _ = fs::remove_dir_all(&run_dir);
1737 }
1738
1739 #[tokio::test]
1740 async fn run_tick_serializes_actions_for_the_same_bot() {
1741 let run_dir = temp_run_dir("same-bot");
1742 let _ = fs::remove_dir_all(&run_dir);
1743 fs::create_dir_all(run_dir.join("blobs")).unwrap();
1744 let paths = crate::BeamPaths::from_root(run_dir.clone());
1745 let params = BTreeMap::new();
1746 let run_id = "run-same-bot";
1747 crate::bootstrap_workflow_run(
1748 &paths,
1749 crate::BootstrapWorkflowRunInput {
1750 run_id,
1751 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"}}}"#,
1752 expected_workflow_id: Some("flow-same-bot"),
1753 params: ¶ms,
1754 initiator: "cli",
1755 chat_binding: Some(RunChatBinding {
1756 chat_id: "chat-1".to_string(),
1757 lark_app_id: "app-1".to_string(),
1758 }),
1759 },
1760 )
1761 .unwrap();
1762 let mut rt = WorkflowRuntimeContext {
1763 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
1764 def: WorkflowDefinition {
1765 workflow_id: "flow-same-bot".to_string(),
1766 version: 1,
1767 params: None,
1768 defaults: None,
1769 nodes: BTreeMap::from([
1770 (
1771 "a".to_string(),
1772 WorkflowNode::Subagent(SubagentNode {
1773 base: NodeBase {
1774 description: None,
1775 depends: None,
1776 human_gate: None,
1777 retry_policy: None,
1778 timeout_ms: None,
1779 max_output_bytes: None,
1780 output_schema: None,
1781 unsafe_allow_ungated: None,
1782 },
1783 bot: "bot-shared".to_string(),
1784 prompt: Value::String("one".to_string()),
1785 working_dir: None,
1786 model_overrides: None,
1787 tool_policy: None,
1788 }),
1789 ),
1790 (
1791 "b".to_string(),
1792 WorkflowNode::Subagent(SubagentNode {
1793 base: NodeBase {
1794 description: None,
1795 depends: None,
1796 human_gate: None,
1797 retry_policy: None,
1798 timeout_ms: None,
1799 max_output_bytes: None,
1800 output_schema: None,
1801 unsafe_allow_ungated: None,
1802 },
1803 bot: "bot-shared".to_string(),
1804 prompt: Value::String("two".to_string()),
1805 working_dir: None,
1806 model_overrides: None,
1807 tool_policy: None,
1808 }),
1809 ),
1810 (
1811 "c".to_string(),
1812 WorkflowNode::Subagent(SubagentNode {
1813 base: NodeBase {
1814 description: None,
1815 depends: None,
1816 human_gate: None,
1817 retry_policy: None,
1818 timeout_ms: None,
1819 max_output_bytes: None,
1820 output_schema: None,
1821 unsafe_allow_ungated: None,
1822 },
1823 bot: "bot-other".to_string(),
1824 prompt: Value::String("three".to_string()),
1825 working_dir: None,
1826 model_overrides: None,
1827 tool_policy: None,
1828 }),
1829 ),
1830 ]),
1831 },
1832 runs_base_dir: paths.workflow_runs_dir(),
1833 };
1834
1835 #[derive(Clone)]
1836 struct SerializingHooks {
1837 started: Arc<Mutex<Vec<String>>>,
1838 active_by_bot: Arc<Mutex<HashMap<String, usize>>>,
1839 max_active_by_bot: Arc<Mutex<HashMap<String, usize>>>,
1840 }
1841
1842 #[async_trait]
1843 impl WorkflowExecutionHooks for SerializingHooks {
1844 async fn execute_subagent(
1845 &mut self,
1846 _ctx: WorkflowDispatchRun<'_>,
1847 node: &SubagentNode,
1848 resolved_prompt: String,
1849 ) -> Result<WorkflowDispatchOutcome> {
1850 let bot = node.bot.clone();
1851 {
1852 let mut active = self.active_by_bot.lock().await;
1853 let entry = active.entry(bot.clone()).or_insert(0);
1854 *entry += 1;
1855 let mut max_active = self.max_active_by_bot.lock().await;
1856 let max_entry = max_active.entry(bot.clone()).or_insert(0);
1857 if *entry > *max_entry {
1858 *max_entry = *entry;
1859 }
1860 assert!(
1861 *entry <= 1,
1862 "bot {} ran concurrently with itself: {}",
1863 bot,
1864 *entry
1865 );
1866 }
1867 self.started.lock().await.push(resolved_prompt.clone());
1868 tokio::time::sleep(std::time::Duration::from_millis(120)).await;
1869 {
1870 let mut active = self.active_by_bot.lock().await;
1871 if let Some(entry) = active.get_mut(&bot) {
1872 *entry = entry.saturating_sub(1);
1873 }
1874 }
1875 Ok(WorkflowDispatchOutcome::Succeeded {
1876 output: Value::String(resolved_prompt),
1877 session: None,
1878 })
1879 }
1880
1881 async fn execute_host_executor(
1882 &mut self,
1883 _ctx: WorkflowDispatchRun<'_>,
1884 _node: &HostExecutorNode,
1885 resolved_input: Value,
1886 ) -> Result<WorkflowDispatchOutcome> {
1887 Ok(WorkflowDispatchOutcome::Succeeded {
1888 output: resolved_input,
1889 session: None,
1890 })
1891 }
1892 }
1893
1894 let started = Arc::new(Mutex::new(Vec::new()));
1895 let active_by_bot = Arc::new(Mutex::new(HashMap::new()));
1896 let max_active_by_bot = Arc::new(Mutex::new(HashMap::new()));
1897 let mut hooks = SerializingHooks {
1898 started: started.clone(),
1899 active_by_bot: active_by_bot.clone(),
1900 max_active_by_bot: max_active_by_bot.clone(),
1901 };
1902
1903 let started_at = std::time::Instant::now();
1904 let tick = run_tick(&mut rt, &mut hooks, 2).await.unwrap();
1905 let elapsed = started_at.elapsed();
1906
1907 assert_eq!(tick.actions, 2);
1908 let started = started.lock().await.clone();
1909 assert_eq!(started.len(), 2);
1910 assert!(started.contains(&"one".to_string()));
1911 assert!(started.contains(&"three".to_string()));
1912 assert!(!started.contains(&"two".to_string()));
1913 let max_active_by_bot = max_active_by_bot.lock().await;
1914 assert_eq!(max_active_by_bot.get("bot-shared"), Some(&1));
1915 assert_eq!(max_active_by_bot.get("bot-other"), Some(&1));
1916 assert!(
1917 elapsed < std::time::Duration::from_millis(220),
1918 "run_tick took {:?}, expected two distinct bots to run concurrently",
1919 elapsed
1920 );
1921 let _ = fs::remove_dir_all(&run_dir);
1922 }
1923
1924 #[derive(Clone)]
1925 struct CancellingHooks {
1926 run_id: String,
1927 runs_base_dir: PathBuf,
1928 calls: Arc<Mutex<usize>>,
1929 completed: Arc<Mutex<usize>>,
1930 }
1931
1932 #[async_trait]
1933 impl WorkflowExecutionHooks for CancellingHooks {
1934 async fn execute_subagent(
1935 &mut self,
1936 _ctx: WorkflowDispatchRun<'_>,
1937 _node: &SubagentNode,
1938 resolved_prompt: String,
1939 ) -> Result<WorkflowDispatchOutcome> {
1940 let mut calls = self.calls.lock().await;
1941 *calls += 1;
1942 if *calls == 1 {
1943 let run_id = self.run_id.clone();
1944 let runs_base_dir = self.runs_base_dir.clone();
1945 tokio::spawn(async move {
1946 tokio::time::sleep(std::time::Duration::from_millis(1)).await;
1947 let mut log = EventLog::new(run_id.clone(), runs_base_dir).unwrap();
1948 let _ = log.append(EventDraft {
1949 event_type: "cancelRequested".to_string(),
1950 actor: WorkflowActor::Human,
1951 payload: serde_json::json!({
1952 "target": { "kind": "run", "runId": run_id },
1953 "reason": "cancel mid tick",
1954 "by": "tester",
1955 }),
1956 timestamp: None,
1957 payload_hash: None,
1958 });
1959 });
1960 }
1961 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
1962 *self.completed.lock().await += 1;
1963 Ok(WorkflowDispatchOutcome::Succeeded {
1964 output: Value::String(resolved_prompt),
1965 session: None,
1966 })
1967 }
1968
1969 async fn execute_host_executor(
1970 &mut self,
1971 _ctx: WorkflowDispatchRun<'_>,
1972 _node: &HostExecutorNode,
1973 resolved_input: Value,
1974 ) -> Result<WorkflowDispatchOutcome> {
1975 Ok(WorkflowDispatchOutcome::Succeeded {
1976 output: resolved_input,
1977 session: None,
1978 })
1979 }
1980 }
1981
1982 #[tokio::test]
1983 async fn run_tick_stops_between_actions_when_cancel_arrives() {
1984 let run_dir = temp_run_dir("cancel-mid-tick");
1985 let _ = fs::remove_dir_all(&run_dir);
1986 fs::create_dir_all(run_dir.join("blobs")).unwrap();
1987 let paths = crate::BeamPaths::from_root(run_dir.clone());
1988 let run_id = "run-cancel";
1989 crate::bootstrap_workflow_run(
1990 &paths,
1991 crate::BootstrapWorkflowRunInput {
1992 run_id,
1993 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"}}}"#,
1994 expected_workflow_id: Some("flow-cancel"),
1995 params: &BTreeMap::new(),
1996 initiator: "cli",
1997 chat_binding: Some(RunChatBinding {
1998 chat_id: "chat-1".to_string(),
1999 lark_app_id: "app-1".to_string(),
2000 }),
2001 },
2002 )
2003 .unwrap();
2004 let mut rt = WorkflowRuntimeContext {
2005 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2006 def: WorkflowDefinition {
2007 workflow_id: "flow-cancel".to_string(),
2008 version: 1,
2009 params: None,
2010 defaults: None,
2011 nodes: BTreeMap::from([
2012 (
2013 "a".to_string(),
2014 WorkflowNode::Subagent(SubagentNode {
2015 base: NodeBase {
2016 description: None,
2017 depends: None,
2018 human_gate: None,
2019 retry_policy: None,
2020 timeout_ms: None,
2021 max_output_bytes: None,
2022 output_schema: None,
2023 unsafe_allow_ungated: None,
2024 },
2025 bot: "bot-a".to_string(),
2026 prompt: Value::String("one".to_string()),
2027 working_dir: None,
2028 model_overrides: None,
2029 tool_policy: None,
2030 }),
2031 ),
2032 (
2033 "b".to_string(),
2034 WorkflowNode::Subagent(SubagentNode {
2035 base: NodeBase {
2036 description: None,
2037 depends: None,
2038 human_gate: None,
2039 retry_policy: None,
2040 timeout_ms: None,
2041 max_output_bytes: None,
2042 output_schema: None,
2043 unsafe_allow_ungated: None,
2044 },
2045 bot: "bot-b".to_string(),
2046 prompt: Value::String("two".to_string()),
2047 working_dir: None,
2048 model_overrides: None,
2049 tool_policy: None,
2050 }),
2051 ),
2052 ]),
2053 },
2054 runs_base_dir: paths.workflow_runs_dir(),
2055 };
2056 let mut hooks = CancellingHooks {
2057 run_id: run_id.to_string(),
2058 runs_base_dir: paths.workflow_runs_dir(),
2059 calls: Arc::new(Mutex::new(0)),
2060 completed: Arc::new(Mutex::new(0)),
2061 };
2062 let started = std::time::Instant::now();
2063 let tick = run_tick(&mut rt, &mut hooks, 2).await.unwrap();
2064 let elapsed = started.elapsed();
2065 assert!(tick.actions < 2);
2066 assert!(tick.snapshot.run.cancelled_run_intent.is_some());
2067 assert!(tick.snapshot.activities.len() >= 1);
2068 assert!(
2069 elapsed < std::time::Duration::from_millis(250),
2070 "run_tick took {:?}, expected cancel to interrupt long-running actions",
2071 elapsed
2072 );
2073 assert_eq!(*hooks.completed.lock().await, 0);
2074 let _ = fs::remove_dir_all(&run_dir);
2075 }
2076
2077 #[tokio::test]
2078 async fn run_tick_skips_dispatch_when_run_cancel_is_pending() {
2079 let run_dir = temp_run_dir("reconcile-cancel");
2080 let _ = fs::remove_dir_all(&run_dir);
2081 fs::create_dir_all(run_dir.join("blobs")).unwrap();
2082 let paths = crate::BeamPaths::from_root(run_dir.clone());
2083 let run_id = "run-reconcile-cancel";
2084 crate::bootstrap_workflow_run(
2085 &paths,
2086 crate::BootstrapWorkflowRunInput {
2087 run_id,
2088 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"}}}"#,
2089 expected_workflow_id: Some("flow-reconcile"),
2090 params: &BTreeMap::new(),
2091 initiator: "cli",
2092 chat_binding: Some(RunChatBinding {
2093 chat_id: "chat-1".to_string(),
2094 lark_app_id: "app-1".to_string(),
2095 }),
2096 },
2097 )
2098 .unwrap();
2099 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
2100 let _ = crate::request_cancel(
2101 &mut log,
2102 crate::RequestCancelInput {
2103 target: serde_json::json!({
2104 "kind": "run",
2105 "runId": run_id,
2106 }),
2107 reason: "cancel before dispatch".to_string(),
2108 by: "tester".to_string(),
2109 },
2110 WorkflowActor::Human,
2111 )
2112 .await
2113 .unwrap();
2114 let mut rt = WorkflowRuntimeContext {
2115 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2116 def: WorkflowDefinition {
2117 workflow_id: "flow-reconcile".to_string(),
2118 version: 1,
2119 params: None,
2120 defaults: None,
2121 nodes: BTreeMap::from([
2122 (
2123 "a".to_string(),
2124 WorkflowNode::Subagent(SubagentNode {
2125 base: NodeBase {
2126 description: None,
2127 depends: None,
2128 human_gate: None,
2129 retry_policy: None,
2130 timeout_ms: None,
2131 max_output_bytes: None,
2132 output_schema: None,
2133 unsafe_allow_ungated: None,
2134 },
2135 bot: "bot-a".to_string(),
2136 prompt: Value::String("one".to_string()),
2137 working_dir: None,
2138 model_overrides: None,
2139 tool_policy: None,
2140 }),
2141 ),
2142 (
2143 "b".to_string(),
2144 WorkflowNode::Subagent(SubagentNode {
2145 base: NodeBase {
2146 description: None,
2147 depends: None,
2148 human_gate: None,
2149 retry_policy: None,
2150 timeout_ms: None,
2151 max_output_bytes: None,
2152 output_schema: None,
2153 unsafe_allow_ungated: None,
2154 },
2155 bot: "bot-b".to_string(),
2156 prompt: Value::String("two".to_string()),
2157 working_dir: None,
2158 model_overrides: None,
2159 tool_policy: None,
2160 }),
2161 ),
2162 ]),
2163 },
2164 runs_base_dir: paths.workflow_runs_dir(),
2165 };
2166 let mut hooks = FakeHooks;
2167 let tick = run_tick(&mut rt, &mut hooks, 2).await.unwrap();
2168 assert_eq!(tick.actions, 0);
2169 assert!(tick.snapshot.run.cancelled_run_intent.is_some());
2170 let _ = fs::remove_dir_all(&run_dir);
2171 }
2172
2173 #[test]
2174 fn orchestrator_action_is_dispatch_classifies_correctly() {
2175 use crate::workflow_definition::{NodeBase, SubagentNode};
2176 use crate::workflow_orchestrator::OrchestratorAction;
2177 let dispatch = OrchestratorAction::DispatchWork {
2178 node_id: "n1".to_string(),
2179 activity_id: "a1".to_string(),
2180 node: crate::WorkflowNode::Subagent(SubagentNode {
2181 base: NodeBase {
2182 description: None,
2183 depends: None,
2184 human_gate: None,
2185 retry_policy: None,
2186 timeout_ms: None,
2187 max_output_bytes: None,
2188 output_schema: None,
2189 unsafe_allow_ungated: None,
2190 },
2191 bot: "b1".to_string(),
2192 prompt: serde_json::json!("p"),
2193 working_dir: None,
2194 model_overrides: None,
2195 tool_policy: None,
2196 }),
2197 };
2198 assert!(dispatch.is_dispatch());
2199
2200 let settle = OrchestratorAction::CompleteNodeSucceeded {
2201 node_id: "n1".to_string(),
2202 last_activity_id: "a1".to_string(),
2203 output_ref: None,
2204 };
2205 assert!(!settle.is_dispatch());
2206 }
2207
2208 #[tokio::test]
2209 async fn run_loop_stops_when_progress_is_exhausted() {
2210 let run_dir = temp_run_dir("loop");
2211 let _ = fs::remove_dir_all(&run_dir);
2212 fs::create_dir_all(run_dir.join("blobs")).unwrap();
2213 let paths = crate::BeamPaths::from_root(run_dir.clone());
2214 let params = BTreeMap::from([(String::from("name"), String::from("beam"))]);
2215 let run_id = "run-1";
2216 crate::bootstrap_workflow_run(
2217 &paths,
2218 crate::BootstrapWorkflowRunInput {
2219 run_id,
2220 workflow_json: r#"{"workflowId":"flow-a","version":1,"nodes":{"a":{"type":"subagent","bot":"bot-a","prompt":"hello ${params.name}"}}}"#,
2221 expected_workflow_id: Some("flow-a"),
2222 params: ¶ms,
2223 initiator: "cli",
2224 chat_binding: Some(RunChatBinding {
2225 chat_id: "chat-1".to_string(),
2226 lark_app_id: "app-1".to_string(),
2227 }),
2228 },
2229 )
2230 .unwrap();
2231 let mut rt = WorkflowRuntimeContext {
2232 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2233 def: workflow_def(),
2234 runs_base_dir: paths.workflow_runs_dir(),
2235 };
2236 let mut hooks = FakeHooks;
2237 let result = run_loop(&mut rt, &mut hooks, 3, 1).await.unwrap();
2238 assert!(matches!(
2239 result.reason,
2240 RunLoopStopReason::Terminal | RunLoopStopReason::NoProgress
2241 ));
2242 assert!(result.ticks > 0);
2243 let _ = fs::remove_dir_all(&run_dir);
2244 }
2245
2246 #[derive(Clone)]
2250 struct RecoveryCountingHooks {
2251 recovery_calls: Arc<Mutex<usize>>,
2252 }
2253
2254 #[async_trait]
2255 impl WorkflowExecutionHooks for RecoveryCountingHooks {
2256 async fn execute_subagent(
2257 &mut self,
2258 _ctx: WorkflowDispatchRun<'_>,
2259 _node: &SubagentNode,
2260 resolved_prompt: String,
2261 ) -> Result<WorkflowDispatchOutcome> {
2262 Ok(WorkflowDispatchOutcome::Succeeded {
2263 output: Value::String(resolved_prompt),
2264 session: None,
2265 })
2266 }
2267
2268 async fn execute_host_executor(
2269 &mut self,
2270 _ctx: WorkflowDispatchRun<'_>,
2271 _node: &HostExecutorNode,
2272 resolved_input: Value,
2273 ) -> Result<WorkflowDispatchOutcome> {
2274 Ok(WorkflowDispatchOutcome::Succeeded {
2275 output: resolved_input,
2276 session: None,
2277 })
2278 }
2279
2280 async fn recover_dangling_effects(
2281 &mut self,
2282 _log: &mut EventLog,
2283 _snapshot: &RunSnapshotDTO,
2284 ) -> Result<RecoveryResult> {
2285 *self.recovery_calls.lock().await += 1;
2286 Ok(RecoveryResult {
2287 had_progress: false,
2288 has_remaining_dangling: true,
2289 })
2290 }
2291 }
2292
2293 #[tokio::test]
2294 async fn run_loop_calls_recovery_when_dangling_effects_present() {
2295 let run_dir = temp_run_dir("recovery-call");
2296 let _ = fs::remove_dir_all(&run_dir);
2297 fs::create_dir_all(run_dir.join("blobs")).unwrap();
2298 let paths = crate::BeamPaths::from_root(run_dir.clone());
2299 let run_id = "run-recovery-call";
2300 let _ = crate::bootstrap_workflow_run(
2301 &paths,
2302 crate::BootstrapWorkflowRunInput {
2303 run_id,
2304 workflow_json: r#"{"workflowId":"flow-recovery","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-send","input":{"chatId":"chat-1","content":"hello"},"unsafeAllowUngated":true}}}"#,
2305 expected_workflow_id: Some("flow-recovery"),
2306 params: &BTreeMap::new(),
2307 initiator: "cli",
2308 chat_binding: None,
2309 },
2310 )
2311 .unwrap();
2312
2313 let work_activity_id = format!("{}::work::a", run_id);
2315 let work_attempt_id = format!("{}::work::a::att-1", run_id);
2316
2317 {
2320 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
2321 let _ = log
2322 .append(EventDraft {
2323 event_type: "attemptCreated".to_string(),
2324 actor: WorkflowActor::Scheduler,
2325 payload: serde_json::json!({
2326 "nodeId": "a",
2327 "activityId": &work_activity_id,
2328 "attemptId": &work_attempt_id,
2329 "attemptNumber": 1,
2330 "inputRef": {
2331 "outputHash": "sha256:aa",
2332 "outputPath": "/tmp/aa",
2333 "outputBytes": 2,
2334 "outputSchemaVersion": 1,
2335 "contentType": "application/json"
2336 }
2337 }),
2338 timestamp: None,
2339 payload_hash: None,
2340 })
2341 .unwrap();
2342 let _ = log
2343 .append(EventDraft {
2344 event_type: "effectAttempted".to_string(),
2345 actor: WorkflowActor::HostExecutor,
2346 payload: serde_json::json!({
2347 "activityId": &work_activity_id,
2348 "attemptId": &work_attempt_id,
2349 "idempotencyKey": "wf_key_recovery",
2350 "inputHash": "sha256:bb",
2351 "idempotencyTtlMs": 60000u64,
2352 "provider": "feishu-im",
2353 }),
2354 timestamp: None,
2355 payload_hash: None,
2356 })
2357 .unwrap();
2358 }
2359
2360 let mut rt = WorkflowRuntimeContext {
2361 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2362 def: WorkflowDefinition {
2363 workflow_id: "flow-recovery".to_string(),
2364 version: 1,
2365 params: None,
2366 defaults: None,
2367 nodes: BTreeMap::from([(
2368 "a".to_string(),
2369 WorkflowNode::HostExecutor(HostExecutorNode {
2370 base: NodeBase {
2371 description: None,
2372 depends: None,
2373 human_gate: None,
2374 retry_policy: None,
2375 timeout_ms: None,
2376 max_output_bytes: None,
2377 output_schema: None,
2378 unsafe_allow_ungated: Some(true),
2379 },
2380 executor: "feishu-send".to_string(),
2381 input: serde_json::json!({"chatId":"chat-1","content":"hello"}),
2382 }),
2383 )]),
2384 },
2385 runs_base_dir: paths.workflow_runs_dir(),
2386 };
2387
2388 let recovery_calls = Arc::new(Mutex::new(0usize));
2389 let mut hooks = RecoveryCountingHooks {
2390 recovery_calls: recovery_calls.clone(),
2391 };
2392 let result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
2393
2394 let calls = *recovery_calls.lock().await;
2395 assert!(
2396 calls > 0,
2397 "expected recover_dangling_effects to be called at least once, got {calls}"
2398 );
2399 assert!(
2402 matches!(
2403 result.reason,
2404 RunLoopStopReason::NoProgress | RunLoopStopReason::AwaitingWait
2405 ),
2406 "expected NoProgress or AwaitingWait, got {:?}",
2407 result.reason
2408 );
2409 let _ = fs::remove_dir_all(&run_dir);
2410 }
2411
2412 #[derive(Clone)]
2415 struct RecoveringHooks;
2416
2417 #[async_trait]
2418 impl WorkflowExecutionHooks for RecoveringHooks {
2419 async fn execute_subagent(
2420 &mut self,
2421 _ctx: WorkflowDispatchRun<'_>,
2422 _node: &SubagentNode,
2423 resolved_prompt: String,
2424 ) -> Result<WorkflowDispatchOutcome> {
2425 Ok(WorkflowDispatchOutcome::Succeeded {
2426 output: Value::String(resolved_prompt),
2427 session: None,
2428 })
2429 }
2430
2431 async fn execute_host_executor(
2432 &mut self,
2433 _ctx: WorkflowDispatchRun<'_>,
2434 _node: &HostExecutorNode,
2435 resolved_input: Value,
2436 ) -> Result<WorkflowDispatchOutcome> {
2437 Ok(WorkflowDispatchOutcome::Succeeded {
2438 output: resolved_input,
2439 session: None,
2440 })
2441 }
2442
2443 async fn recover_dangling_effects(
2444 &mut self,
2445 log: &mut EventLog,
2446 snapshot: &RunSnapshotDTO,
2447 ) -> Result<RecoveryResult> {
2448 let mut had_progress = false;
2449 for activity_id in &snapshot.dangling.effect_attempted {
2450 if let Some(activity) = snapshot
2451 .activities
2452 .iter()
2453 .find(|a| &a.activity_id == activity_id)
2454 {
2455 if let Some(latest) = activity.attempts.last() {
2456 let output_ref = WorkflowOutputRef {
2457 output_hash: "sha256:recovered".to_string(),
2458 output_path: "/tmp/recovered".to_string(),
2459 output_bytes: 3,
2460 output_schema_version: 1,
2461 content_type: Some("application/json".to_string()),
2462 };
2463 let _ = log.append(EventDraft {
2464 event_type: "activitySucceeded".to_string(),
2465 actor: WorkflowActor::System,
2466 payload: serde_json::json!({
2467 "activityId": activity_id,
2468 "attemptId": &latest.attempt_id,
2469 "outputRef": output_ref,
2470 }),
2471 timestamp: None,
2472 payload_hash: None,
2473 })?;
2474 had_progress = true;
2475 }
2476 }
2477 }
2478 Ok(RecoveryResult {
2479 had_progress,
2480 has_remaining_dangling: false,
2481 })
2482 }
2483 }
2484
2485 #[tokio::test]
2486 async fn run_loop_replays_after_recovery_writes_events() {
2487 let run_dir = temp_run_dir("recovery-replay");
2488 let _ = fs::remove_dir_all(&run_dir);
2489 fs::create_dir_all(run_dir.join("blobs")).unwrap();
2490 let paths = crate::BeamPaths::from_root(run_dir.clone());
2491 let run_id = "run-recovery-replay";
2492 let _ = crate::bootstrap_workflow_run(
2493 &paths,
2494 crate::BootstrapWorkflowRunInput {
2495 run_id,
2496 workflow_json: r#"{"workflowId":"flow-replay","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-send","input":{"chatId":"chat-1","content":"hello"},"unsafeAllowUngated":true}}}"#,
2497 expected_workflow_id: Some("flow-replay"),
2498 params: &BTreeMap::new(),
2499 initiator: "cli",
2500 chat_binding: None,
2501 },
2502 )
2503 .unwrap();
2504
2505 let work_activity_id = format!("{}::work::a", run_id);
2507 let work_attempt_id = format!("{}::work::a::att-1", run_id);
2508
2509 {
2511 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
2512 let _ = log
2513 .append(EventDraft {
2514 event_type: "attemptCreated".to_string(),
2515 actor: WorkflowActor::Scheduler,
2516 payload: serde_json::json!({
2517 "nodeId": "a",
2518 "activityId": &work_activity_id,
2519 "attemptId": &work_attempt_id,
2520 "attemptNumber": 1,
2521 "inputRef": {
2522 "outputHash": "sha256:aa",
2523 "outputPath": "/tmp/aa",
2524 "outputBytes": 2,
2525 "outputSchemaVersion": 1,
2526 "contentType": "application/json"
2527 }
2528 }),
2529 timestamp: None,
2530 payload_hash: None,
2531 })
2532 .unwrap();
2533 let _ = log
2534 .append(EventDraft {
2535 event_type: "effectAttempted".to_string(),
2536 actor: WorkflowActor::HostExecutor,
2537 payload: serde_json::json!({
2538 "activityId": &work_activity_id,
2539 "attemptId": &work_attempt_id,
2540 "idempotencyKey": "wf_key_replay",
2541 "inputHash": "sha256:bb",
2542 "idempotencyTtlMs": 60000u64,
2543 "provider": "feishu-im",
2544 }),
2545 timestamp: None,
2546 payload_hash: None,
2547 })
2548 .unwrap();
2549 }
2550
2551 let mut rt = WorkflowRuntimeContext {
2552 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2553 def: WorkflowDefinition {
2554 workflow_id: "flow-replay".to_string(),
2555 version: 1,
2556 params: None,
2557 defaults: None,
2558 nodes: BTreeMap::from([(
2559 "a".to_string(),
2560 WorkflowNode::HostExecutor(HostExecutorNode {
2561 base: NodeBase {
2562 description: None,
2563 depends: None,
2564 human_gate: None,
2565 retry_policy: None,
2566 timeout_ms: None,
2567 max_output_bytes: None,
2568 output_schema: None,
2569 unsafe_allow_ungated: Some(true),
2570 },
2571 executor: "feishu-send".to_string(),
2572 input: serde_json::json!({"chatId":"chat-1","content":"hello"}),
2573 }),
2574 )]),
2575 },
2576 runs_base_dir: paths.workflow_runs_dir(),
2577 };
2578
2579 let mut hooks = RecoveringHooks;
2580 let result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
2581
2582 let final_snapshot = read_snapshot(&rt).await.unwrap();
2586 assert!(
2587 matches!(
2588 final_snapshot.run.status,
2589 RunStatus::Succeeded | RunStatus::Failed | RunStatus::Cancelled
2590 ) || result.ticks > 0,
2591 "expected recovery to allow progress; reason={:?} ticks={} status={:?}",
2592 result.reason,
2593 result.ticks,
2594 final_snapshot.run.status
2595 );
2596 let events = rt.log.read_all().unwrap();
2598 let has_recovered = events
2599 .iter()
2600 .any(|e| e.event_type == "activitySucceeded" && e.actor == WorkflowActor::System);
2601 assert!(
2602 has_recovered,
2603 "expected a system activitySucceeded from recovery"
2604 );
2605 let _ = fs::remove_dir_all(&run_dir);
2606 }
2607
2608 #[tokio::test]
2609 async fn run_loop_no_infinite_loop_when_recovery_cannot_progress() {
2610 let run_dir = temp_run_dir("recovery-stuck");
2611 let _ = fs::remove_dir_all(&run_dir);
2612 fs::create_dir_all(run_dir.join("blobs")).unwrap();
2613 let paths = crate::BeamPaths::from_root(run_dir.clone());
2614 let run_id = "run-recovery-stuck";
2615 let _ = crate::bootstrap_workflow_run(
2616 &paths,
2617 crate::BootstrapWorkflowRunInput {
2618 run_id,
2619 workflow_json: r#"{"workflowId":"flow-stuck","version":1,"nodes":{"a":{"type":"hostExecutor","executor":"feishu-send","input":{"chatId":"chat-1","content":"hello"},"unsafeAllowUngated":true}}}"#,
2620 expected_workflow_id: Some("flow-stuck"),
2621 params: &BTreeMap::new(),
2622 initiator: "cli",
2623 chat_binding: None,
2624 },
2625 )
2626 .unwrap();
2627
2628 let work_activity_id = format!("{}::work::a", run_id);
2630 let work_attempt_id = format!("{}::work::a::att-1", run_id);
2631
2632 {
2634 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
2635 let _ = log
2636 .append(EventDraft {
2637 event_type: "attemptCreated".to_string(),
2638 actor: WorkflowActor::Scheduler,
2639 payload: serde_json::json!({
2640 "nodeId": "a",
2641 "activityId": &work_activity_id,
2642 "attemptId": &work_attempt_id,
2643 "attemptNumber": 1,
2644 "inputRef": {
2645 "outputHash": "sha256:aa",
2646 "outputPath": "/tmp/aa",
2647 "outputBytes": 2,
2648 "outputSchemaVersion": 1,
2649 "contentType": "application/json"
2650 }
2651 }),
2652 timestamp: None,
2653 payload_hash: None,
2654 })
2655 .unwrap();
2656 let _ = log
2657 .append(EventDraft {
2658 event_type: "effectAttempted".to_string(),
2659 actor: WorkflowActor::HostExecutor,
2660 payload: serde_json::json!({
2661 "activityId": &work_activity_id,
2662 "attemptId": &work_attempt_id,
2663 "idempotencyKey": "wf_key_stuck",
2664 "inputHash": "sha256:bb",
2665 "idempotencyTtlMs": 60000u64,
2666 "provider": "unknown-provider",
2667 }),
2668 timestamp: None,
2669 payload_hash: None,
2670 })
2671 .unwrap();
2672 }
2673
2674 let mut rt = WorkflowRuntimeContext {
2675 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2676 def: WorkflowDefinition {
2677 workflow_id: "flow-stuck".to_string(),
2678 version: 1,
2679 params: None,
2680 defaults: None,
2681 nodes: BTreeMap::from([(
2682 "a".to_string(),
2683 WorkflowNode::HostExecutor(HostExecutorNode {
2684 base: NodeBase {
2685 description: None,
2686 depends: None,
2687 human_gate: None,
2688 retry_policy: None,
2689 timeout_ms: None,
2690 max_output_bytes: None,
2691 output_schema: None,
2692 unsafe_allow_ungated: Some(true),
2693 },
2694 executor: "feishu-send".to_string(),
2695 input: serde_json::json!({"chatId":"chat-1","content":"hello"}),
2696 }),
2697 )]),
2698 },
2699 runs_base_dir: paths.workflow_runs_dir(),
2700 };
2701
2702 let mut hooks = FakeHooks;
2704 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
2705
2706 assert!(
2710 !matches!(result.reason, RunLoopStopReason::MaxTicks),
2711 "expected run_loop to stop without exhausting max_ticks on unrecoverable dangling effects"
2712 );
2713 assert!(
2714 matches!(
2715 result.reason,
2716 RunLoopStopReason::NoProgress | RunLoopStopReason::AwaitingWait
2717 ),
2718 "expected NoProgress or AwaitingWait, got {:?}",
2719 result.reason
2720 );
2721 let _ = fs::remove_dir_all(&run_dir);
2722 }
2723
2724 #[tokio::test]
2725 async fn default_recovery_result_has_correct_semantics() {
2726 let empty_snapshot = RunSnapshotDTO {
2732 run_id: "r".to_string(),
2733 run: RunState {
2734 run_id: "r".to_string(),
2735 status: RunStatus::Running,
2736 workflow_id: None,
2737 revision_id: None,
2738 initiator: None,
2739 input: None,
2740 output: None,
2741 failed_node_id: None,
2742 root_cause_event_id: None,
2743 cancel_origin_event_id: None,
2744 bot_snapshots: None,
2745 cancelled_run_intent: None,
2746 cancelled_node_intents: BTreeMap::new(),
2747 },
2748 last_seq: 0,
2749 nodes: vec![],
2750 activities: vec![],
2751 loops: None,
2752 dangling: DanglingSnapshot {
2753 activities: vec![],
2754 effect_attempted: vec![],
2755 waits: vec![],
2756 wait_resolutions: vec![],
2757 cancels: vec![],
2758 },
2759 outputs: BTreeMap::new(),
2760 attempt_io: BTreeMap::new(),
2761 chat_binding: None,
2762 updated_at: 0,
2763 };
2764
2765 let run_dir = temp_run_dir("recovery-semantics");
2767 let _ = fs::remove_dir_all(&run_dir);
2768 fs::create_dir_all(&run_dir).unwrap();
2769 let paths = crate::BeamPaths::from_root(run_dir.clone());
2770 let mut log = EventLog::new("r", paths.workflow_runs_dir()).unwrap();
2771
2772 #[derive(Clone)]
2775 struct DefaultingHooks;
2776 #[async_trait]
2777 impl WorkflowExecutionHooks for DefaultingHooks {
2778 async fn execute_subagent(
2779 &mut self,
2780 _ctx: WorkflowDispatchRun<'_>,
2781 _node: &SubagentNode,
2782 _resolved_prompt: String,
2783 ) -> Result<WorkflowDispatchOutcome> {
2784 unreachable!()
2785 }
2786 async fn execute_host_executor(
2787 &mut self,
2788 _ctx: WorkflowDispatchRun<'_>,
2789 _node: &HostExecutorNode,
2790 _resolved_input: Value,
2791 ) -> Result<WorkflowDispatchOutcome> {
2792 unreachable!()
2793 }
2794 }
2796
2797 let mut hooks = DefaultingHooks;
2798
2799 let result = hooks
2801 .recover_dangling_effects(&mut log, &empty_snapshot)
2802 .await
2803 .unwrap();
2804 assert!(!result.had_progress, "default: had_progress must be false");
2805 assert!(
2806 !result.has_remaining_dangling,
2807 "empty dangling → has_remaining_dangling must be false"
2808 );
2809
2810 let dangling_snapshot = RunSnapshotDTO {
2812 dangling: DanglingSnapshot {
2813 effect_attempted: vec!["act-1".to_string()],
2814 ..empty_snapshot.dangling.clone()
2815 },
2816 ..empty_snapshot.clone()
2817 };
2818 let result2 = hooks
2819 .recover_dangling_effects(&mut log, &dangling_snapshot)
2820 .await
2821 .unwrap();
2822 assert!(!result2.had_progress, "default: had_progress must be false");
2823 assert!(
2824 result2.has_remaining_dangling,
2825 "dangling present → has_remaining_dangling must be true"
2826 );
2827
2828 let _ = fs::remove_dir_all(&run_dir);
2829 }
2830
2831 #[tokio::test]
2836 async fn open_wait_makes_run_loop_return_awaiting_wait() {
2837 let run_dir = temp_run_dir("open-wait");
2838 let _ = fs::remove_dir_all(&run_dir);
2839 fs::create_dir_all(run_dir.join("blobs")).unwrap();
2840 let paths = crate::BeamPaths::from_root(run_dir.clone());
2841 let run_id = "run-open-wait";
2842 crate::bootstrap_workflow_run(
2843 &paths,
2844 crate::BootstrapWorkflowRunInput {
2845 run_id,
2846 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"]}}}"#,
2847 expected_workflow_id: Some("flow-open-wait"),
2848 params: &BTreeMap::new(),
2849 initiator: "cli",
2850 chat_binding: Some(RunChatBinding {
2851 chat_id: "chat-1".to_string(),
2852 lark_app_id: "app-1".to_string(),
2853 }),
2854 },
2855 )
2856 .unwrap();
2857
2858 let gate_activity_id = format!("{}::gate::gate", run_id);
2861 let gate_attempt_id = format!("{}::gate::gate::att-1", run_id);
2862 {
2863 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
2864 let _ = log
2865 .append(EventDraft {
2866 event_type: "attemptCreated".to_string(),
2867 actor: WorkflowActor::Scheduler,
2868 payload: serde_json::json!({
2869 "nodeId": "gate",
2870 "activityId": &gate_activity_id,
2871 "attemptId": &gate_attempt_id,
2872 "attemptNumber": 1,
2873 "inputRef": {
2874 "outputHash": "sha256:aa",
2875 "outputPath": "/tmp/aa",
2876 "outputBytes": 2,
2877 "outputSchemaVersion": 1,
2878 "contentType": "application/json"
2879 }
2880 }),
2881 timestamp: None,
2882 payload_hash: None,
2883 })
2884 .unwrap();
2885 let _ = log
2886 .append(EventDraft {
2887 event_type: "waitCreated".to_string(),
2888 actor: WorkflowActor::Scheduler,
2889 payload: serde_json::json!({
2890 "activityId": &gate_activity_id,
2891 "attemptId": &gate_attempt_id,
2892 "nodeId": "gate",
2893 "waitKind": "human-gate",
2894 }),
2895 timestamp: None,
2896 payload_hash: None,
2897 })
2898 .unwrap();
2899 }
2900
2901 let mut rt = WorkflowRuntimeContext {
2902 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
2903 def: WorkflowDefinition {
2904 workflow_id: "flow-open-wait".to_string(),
2905 version: 1,
2906 params: None,
2907 defaults: None,
2908 nodes: BTreeMap::from([
2909 (
2910 "gate".to_string(),
2911 WorkflowNode::HostExecutor(HostExecutorNode {
2912 base: NodeBase {
2913 description: None,
2914 depends: None,
2915 human_gate: Some(crate::workflow_definition::HumanGate {
2916 stage: "gate".to_string(),
2917 prompt: Value::String("approve?".to_string()),
2918 approvers: Some(vec!["admin".to_string()]),
2919 deadline_ms: None,
2920 on_timeout: None,
2921 }),
2922 retry_policy: None,
2923 timeout_ms: None,
2924 max_output_bytes: None,
2925 output_schema: None,
2926 unsafe_allow_ungated: Some(true),
2927 },
2928 executor: "beam-schedule".to_string(),
2929 input: serde_json::json!({"prompt":"approve"}),
2930 }),
2931 ),
2932 (
2933 "sink".to_string(),
2934 WorkflowNode::Subagent(SubagentNode {
2935 base: NodeBase {
2936 description: None,
2937 depends: Some(vec!["gate".to_string()]),
2938 human_gate: None,
2939 retry_policy: None,
2940 timeout_ms: None,
2941 max_output_bytes: None,
2942 output_schema: None,
2943 unsafe_allow_ungated: None,
2944 },
2945 bot: "bot-a".to_string(),
2946 prompt: Value::String("done".to_string()),
2947 working_dir: None,
2948 model_overrides: None,
2949 tool_policy: None,
2950 }),
2951 ),
2952 ]),
2953 },
2954 runs_base_dir: paths.workflow_runs_dir(),
2955 };
2956
2957 let snap = read_snapshot(&rt).await.unwrap();
2959 assert!(
2960 !snap.dangling.waits.is_empty(),
2961 "expected open wait in dangling.waits, got {:?}",
2962 snap.dangling.waits
2963 );
2964 assert!(
2965 snap.dangling.wait_resolutions.is_empty(),
2966 "expected no wait_resolutions for open wait"
2967 );
2968
2969 let mut hooks = FakeHooks;
2970 let result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
2971 assert_eq!(
2972 result.reason,
2973 RunLoopStopReason::AwaitingWait,
2974 "expected AwaitingWait for open wait, got {:?}",
2975 result.reason
2976 );
2977
2978 let _ = fs::remove_dir_all(&run_dir);
2979 }
2980
2981 #[tokio::test]
2985 async fn run_loop_materializes_terminal_for_resolved_wait() {
2986 let run_dir = temp_run_dir("resolved-wait");
2987 let _ = fs::remove_dir_all(&run_dir);
2988 fs::create_dir_all(run_dir.join("blobs")).unwrap();
2989 let paths = crate::BeamPaths::from_root(run_dir.clone());
2990 let run_id = "run-resolved-wait";
2991 crate::bootstrap_workflow_run(
2992 &paths,
2993 crate::BootstrapWorkflowRunInput {
2994 run_id,
2995 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"]}}}"#,
2996 expected_workflow_id: Some("flow-resolved-wait"),
2997 params: &BTreeMap::new(),
2998 initiator: "cli",
2999 chat_binding: Some(RunChatBinding {
3000 chat_id: "chat-1".to_string(),
3001 lark_app_id: "app-1".to_string(),
3002 }),
3003 },
3004 )
3005 .unwrap();
3006
3007 let gate_activity_id = format!("{}::gate::gate", run_id);
3011 let gate_attempt_id = format!("{}::gate::gate::att-1", run_id);
3012 {
3013 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3014 let _ = log
3015 .append(EventDraft {
3016 event_type: "attemptCreated".to_string(),
3017 actor: WorkflowActor::Scheduler,
3018 payload: serde_json::json!({
3019 "nodeId": "gate",
3020 "activityId": &gate_activity_id,
3021 "attemptId": &gate_attempt_id,
3022 "attemptNumber": 1,
3023 "inputRef": {
3024 "outputHash": "sha256:aa",
3025 "outputPath": "/tmp/aa",
3026 "outputBytes": 2,
3027 "outputSchemaVersion": 1,
3028 "contentType": "application/json"
3029 }
3030 }),
3031 timestamp: None,
3032 payload_hash: None,
3033 })
3034 .unwrap();
3035 let _ = log
3036 .append(EventDraft {
3037 event_type: "waitCreated".to_string(),
3038 actor: WorkflowActor::Scheduler,
3039 payload: serde_json::json!({
3040 "activityId": &gate_activity_id,
3041 "attemptId": &gate_attempt_id,
3042 "nodeId": "gate",
3043 "waitKind": "human-gate",
3044 }),
3045 timestamp: None,
3046 payload_hash: None,
3047 })
3048 .unwrap();
3049 let _ = log
3050 .append(EventDraft {
3051 event_type: "waitResolved".to_string(),
3052 actor: WorkflowActor::Human,
3053 payload: serde_json::json!({
3054 "activityId": &gate_activity_id,
3055 "resolution": "approved",
3056 "by": "admin",
3057 "comment": "go ahead",
3058 }),
3059 timestamp: None,
3060 payload_hash: None,
3061 })
3062 .unwrap();
3063 }
3064
3065 let mut rt = WorkflowRuntimeContext {
3066 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
3067 def: WorkflowDefinition {
3068 workflow_id: "flow-resolved-wait".to_string(),
3069 version: 1,
3070 params: None,
3071 defaults: None,
3072 nodes: BTreeMap::from([
3073 (
3074 "gate".to_string(),
3075 WorkflowNode::HostExecutor(HostExecutorNode {
3076 base: NodeBase {
3077 description: None,
3078 depends: None,
3079 human_gate: Some(crate::workflow_definition::HumanGate {
3080 stage: "gate".to_string(),
3081 prompt: Value::String("approve?".to_string()),
3082 approvers: Some(vec!["admin".to_string()]),
3083 deadline_ms: None,
3084 on_timeout: None,
3085 }),
3086 retry_policy: None,
3087 timeout_ms: None,
3088 max_output_bytes: None,
3089 output_schema: None,
3090 unsafe_allow_ungated: Some(true),
3091 },
3092 executor: "beam-schedule".to_string(),
3093 input: serde_json::json!({"prompt":"approve"}),
3094 }),
3095 ),
3096 (
3097 "sink".to_string(),
3098 WorkflowNode::Subagent(SubagentNode {
3099 base: NodeBase {
3100 description: None,
3101 depends: Some(vec!["gate".to_string()]),
3102 human_gate: None,
3103 retry_policy: None,
3104 timeout_ms: None,
3105 max_output_bytes: None,
3106 output_schema: None,
3107 unsafe_allow_ungated: None,
3108 },
3109 bot: "bot-a".to_string(),
3110 prompt: Value::String("done".to_string()),
3111 working_dir: None,
3112 model_overrides: None,
3113 tool_policy: None,
3114 }),
3115 ),
3116 ]),
3117 },
3118 runs_base_dir: paths.workflow_runs_dir(),
3119 };
3120
3121 let snap = read_snapshot(&rt).await.unwrap();
3123 assert!(
3124 snap.dangling.waits.is_empty(),
3125 "expected no open waits after resolution, got {:?}",
3126 snap.dangling.waits
3127 );
3128 assert!(
3129 !snap.dangling.wait_resolutions.is_empty(),
3130 "expected resolved wait in wait_resolutions, got {:?}",
3131 snap.dangling.wait_resolutions
3132 );
3133 assert_eq!(
3134 snap.dangling.wait_resolutions,
3135 vec![gate_activity_id.clone()],
3136 "expected gate activity in wait_resolutions"
3137 );
3138
3139 let mut hooks = FakeHooks;
3140 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
3141
3142 let final_snap = read_snapshot(&rt).await.unwrap();
3145 assert!(
3146 final_snap.dangling.wait_resolutions.is_empty(),
3147 "expected no remaining wait_resolutions after recovery, got {:?}",
3148 final_snap.dangling.wait_resolutions
3149 );
3150
3151 let events = rt.log.read_all().unwrap();
3152 let recovered_terminal = events.iter().any(|e| {
3153 e.event_type == "activitySucceeded"
3154 && e.payload.get("activityId").and_then(Value::as_str) == Some(&gate_activity_id)
3155 && e.actor == WorkflowActor::Scheduler
3156 });
3157 assert!(
3158 recovered_terminal,
3159 "expected a scheduler activitySucceeded for the gate after wait resolution recovery"
3160 );
3161
3162 assert!(
3164 result.ticks > 0 || matches!(result.reason, RunLoopStopReason::Terminal),
3165 "expected progress after wait resolution recovery; reason={:?} ticks={}",
3166 result.reason,
3167 result.ticks
3168 );
3169
3170 let _ = fs::remove_dir_all(&run_dir);
3171 }
3172
3173 #[tokio::test]
3176 async fn cancel_propagation_writes_activity_canceled_before_run_canceled() {
3177 let run_dir = temp_run_dir("cancel-propagate");
3178 let _ = fs::remove_dir_all(&run_dir);
3179 fs::create_dir_all(run_dir.join("blobs")).unwrap();
3180 let paths = crate::BeamPaths::from_root(run_dir.clone());
3181 let run_id = "run-cancel-propagate";
3182 crate::bootstrap_workflow_run(
3183 &paths,
3184 crate::BootstrapWorkflowRunInput {
3185 run_id,
3186 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"]}}}"#,
3187 expected_workflow_id: Some("flow-cancel-propagate"),
3188 params: &BTreeMap::new(),
3189 initiator: "cli",
3190 chat_binding: Some(RunChatBinding {
3191 chat_id: "chat-1".to_string(),
3192 lark_app_id: "app-1".to_string(),
3193 }),
3194 },
3195 )
3196 .unwrap();
3197
3198 let gate_activity_id = format!("{}::gate::gate", run_id);
3200 {
3201 let mut rt = WorkflowRuntimeContext {
3202 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
3203 def: WorkflowDefinition {
3204 workflow_id: "flow-cancel-propagate".to_string(),
3205 version: 1,
3206 params: None,
3207 defaults: None,
3208 nodes: BTreeMap::from([
3209 (
3210 "gate".to_string(),
3211 WorkflowNode::HostExecutor(HostExecutorNode {
3212 base: NodeBase {
3213 description: None,
3214 depends: None,
3215 human_gate: Some(crate::workflow_definition::HumanGate {
3216 stage: "gate".to_string(),
3217 prompt: Value::String("approve?".to_string()),
3218 approvers: Some(vec!["admin".to_string()]),
3219 deadline_ms: None,
3220 on_timeout: None,
3221 }),
3222 retry_policy: None,
3223 timeout_ms: None,
3224 max_output_bytes: None,
3225 output_schema: None,
3226 unsafe_allow_ungated: Some(true),
3227 },
3228 executor: "beam-schedule".to_string(),
3229 input: serde_json::json!({"prompt":"approve"}),
3230 }),
3231 ),
3232 (
3233 "sink".to_string(),
3234 WorkflowNode::Subagent(SubagentNode {
3235 base: NodeBase {
3236 description: None,
3237 depends: Some(vec!["gate".to_string()]),
3238 human_gate: None,
3239 retry_policy: None,
3240 timeout_ms: None,
3241 max_output_bytes: None,
3242 output_schema: None,
3243 unsafe_allow_ungated: None,
3244 },
3245 bot: "bot-a".to_string(),
3246 prompt: Value::String("done".to_string()),
3247 working_dir: None,
3248 model_overrides: None,
3249 tool_policy: None,
3250 }),
3251 ),
3252 ]),
3253 },
3254 runs_base_dir: paths.workflow_runs_dir(),
3255 };
3256 let mut hooks = FakeHooks;
3257 let result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
3258 assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
3259 }
3260
3261 {
3263 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3264 let _ = crate::request_cancel(
3265 &mut log,
3266 crate::RequestCancelInput {
3267 target: serde_json::json!({
3268 "kind": "run",
3269 "runId": run_id,
3270 }),
3271 reason: "test cancel".to_string(),
3272 by: "tester".to_string(),
3273 },
3274 WorkflowActor::Human,
3275 )
3276 .await
3277 .unwrap();
3278 }
3279
3280 {
3282 let mut rt = WorkflowRuntimeContext {
3283 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
3284 def: WorkflowDefinition {
3285 workflow_id: "flow-cancel-propagate".to_string(),
3286 version: 1,
3287 params: None,
3288 defaults: None,
3289 nodes: BTreeMap::from([
3290 (
3291 "gate".to_string(),
3292 WorkflowNode::HostExecutor(HostExecutorNode {
3293 base: NodeBase {
3294 description: None,
3295 depends: None,
3296 human_gate: Some(crate::workflow_definition::HumanGate {
3297 stage: "gate".to_string(),
3298 prompt: Value::String("approve?".to_string()),
3299 approvers: Some(vec!["admin".to_string()]),
3300 deadline_ms: None,
3301 on_timeout: None,
3302 }),
3303 retry_policy: None,
3304 timeout_ms: None,
3305 max_output_bytes: None,
3306 output_schema: None,
3307 unsafe_allow_ungated: Some(true),
3308 },
3309 executor: "beam-schedule".to_string(),
3310 input: serde_json::json!({"prompt":"approve"}),
3311 }),
3312 ),
3313 (
3314 "sink".to_string(),
3315 WorkflowNode::Subagent(SubagentNode {
3316 base: NodeBase {
3317 description: None,
3318 depends: Some(vec!["gate".to_string()]),
3319 human_gate: None,
3320 retry_policy: None,
3321 timeout_ms: None,
3322 max_output_bytes: None,
3323 output_schema: None,
3324 unsafe_allow_ungated: None,
3325 },
3326 bot: "bot-a".to_string(),
3327 prompt: Value::String("done".to_string()),
3328 working_dir: None,
3329 model_overrides: None,
3330 tool_policy: None,
3331 }),
3332 ),
3333 ]),
3334 },
3335 runs_base_dir: paths.workflow_runs_dir(),
3336 };
3337 let mut hooks = FakeHooks;
3338 let result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
3339 assert_eq!(result.reason, RunLoopStopReason::Terminal);
3340 }
3341
3342 let log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3344 let events = log.read_all().unwrap();
3345 let has_cancel_requested = events.iter().any(|e| e.event_type == "cancelRequested");
3346 assert!(has_cancel_requested, "should have cancelRequested");
3347
3348 let mut activity_canceled_pos: Option<usize> = None;
3350 let mut run_canceled_pos: Option<usize> = None;
3351 for (i, e) in events.iter().enumerate() {
3352 if e.event_type == "activityCanceled"
3353 && e.payload.get("activityId").and_then(Value::as_str) == Some(&gate_activity_id)
3354 {
3355 activity_canceled_pos = Some(i);
3356 }
3357 if e.event_type == "runCanceled" {
3358 run_canceled_pos = Some(i);
3359 }
3360 }
3361
3362 assert!(
3363 activity_canceled_pos.is_some(),
3364 "should have activityCanceled for the gate activity"
3365 );
3366 assert!(run_canceled_pos.is_some(), "should have runCanceled");
3367 assert!(
3368 activity_canceled_pos.unwrap() < run_canceled_pos.unwrap(),
3369 "activityCanceled ({}) must appear before runCanceled ({})",
3370 activity_canceled_pos.unwrap(),
3371 run_canceled_pos.unwrap()
3372 );
3373
3374 let _ = fs::remove_dir_all(&run_dir);
3375 }
3376
3377 #[tokio::test]
3380 async fn cancel_propagation_is_idempotent_after_run_is_cancelled() {
3381 let run_dir = temp_run_dir("cancel-idempotent");
3382 let _ = fs::remove_dir_all(&run_dir);
3383 fs::create_dir_all(run_dir.join("blobs")).unwrap();
3384 let paths = crate::BeamPaths::from_root(run_dir.clone());
3385 let run_id = "run-cancel-idempotent";
3386 crate::bootstrap_workflow_run(
3387 &paths,
3388 crate::BootstrapWorkflowRunInput {
3389 run_id,
3390 workflow_json: r#"{"workflowId":"flow-cancel-idem","version":1,"nodes":{"a":{"type":"subagent","bot":"bot-a","prompt":"hello"}}}"#,
3391 expected_workflow_id: Some("flow-cancel-idem"),
3392 params: &BTreeMap::new(),
3393 initiator: "cli",
3394 chat_binding: Some(RunChatBinding {
3395 chat_id: "chat-1".to_string(),
3396 lark_app_id: "app-1".to_string(),
3397 }),
3398 },
3399 )
3400 .unwrap();
3401
3402 {
3404 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3405 let _ = crate::request_cancel(
3406 &mut log,
3407 crate::RequestCancelInput {
3408 target: serde_json::json!({
3409 "kind": "run",
3410 "runId": run_id,
3411 }),
3412 reason: "test cancel".to_string(),
3413 by: "tester".to_string(),
3414 },
3415 WorkflowActor::Human,
3416 )
3417 .await
3418 .unwrap();
3419 }
3420
3421 {
3423 let mut rt = WorkflowRuntimeContext {
3424 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
3425 def: WorkflowDefinition {
3426 workflow_id: "flow-cancel-idem".to_string(),
3427 version: 1,
3428 params: None,
3429 defaults: None,
3430 nodes: BTreeMap::from([(
3431 "a".to_string(),
3432 WorkflowNode::Subagent(SubagentNode {
3433 base: NodeBase {
3434 description: None,
3435 depends: None,
3436 human_gate: None,
3437 retry_policy: None,
3438 timeout_ms: None,
3439 max_output_bytes: None,
3440 output_schema: None,
3441 unsafe_allow_ungated: None,
3442 },
3443 bot: "bot-a".to_string(),
3444 prompt: Value::String("hello".to_string()),
3445 working_dir: None,
3446 model_overrides: None,
3447 tool_policy: None,
3448 }),
3449 )]),
3450 },
3451 runs_base_dir: paths.workflow_runs_dir(),
3452 };
3453 let mut hooks = FakeHooks;
3454 let result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
3455 assert_eq!(result.reason, RunLoopStopReason::Terminal);
3456 }
3457
3458 let run_canceled_count_before: usize;
3460 {
3461 let log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3462 let events = log.read_all().unwrap();
3463 run_canceled_count_before = events
3464 .iter()
3465 .filter(|e| e.event_type == "runCanceled")
3466 .count();
3467 assert_eq!(
3468 run_canceled_count_before, 1,
3469 "should have exactly 1 runCanceled after first propagation"
3470 );
3471 }
3472
3473 {
3474 let mut rt = WorkflowRuntimeContext {
3475 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
3476 def: WorkflowDefinition {
3477 workflow_id: "flow-cancel-idem".to_string(),
3478 version: 1,
3479 params: None,
3480 defaults: None,
3481 nodes: BTreeMap::from([(
3482 "a".to_string(),
3483 WorkflowNode::Subagent(SubagentNode {
3484 base: NodeBase {
3485 description: None,
3486 depends: None,
3487 human_gate: None,
3488 retry_policy: None,
3489 timeout_ms: None,
3490 max_output_bytes: None,
3491 output_schema: None,
3492 unsafe_allow_ungated: None,
3493 },
3494 bot: "bot-a".to_string(),
3495 prompt: Value::String("hello".to_string()),
3496 working_dir: None,
3497 model_overrides: None,
3498 tool_policy: None,
3499 }),
3500 )]),
3501 },
3502 runs_base_dir: paths.workflow_runs_dir(),
3503 };
3504 let mut hooks = FakeHooks;
3505 let _ = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
3506 }
3507
3508 let log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3509 let events = log.read_all().unwrap();
3510 let run_canceled_count_after = events
3511 .iter()
3512 .filter(|e| e.event_type == "runCanceled")
3513 .count();
3514 assert_eq!(
3515 run_canceled_count_after, 1,
3516 "second run_loop should not produce duplicate runCanceled"
3517 );
3518
3519 let _ = fs::remove_dir_all(&run_dir);
3520 }
3521
3522 fn min_workflow_json(workflow_id: &str, node_id: &str) -> String {
3526 format!(
3527 r#"{{"workflowId":"{workflow_id}","version":1,"nodes":{{"{node_id}":{{"type":"subagent","bot":"bot-x","prompt":"ok"}}}}}}"#
3528 )
3529 }
3530
3531 #[tokio::test]
3532 async fn start_loop_writes_loop_started_event() {
3533 let run_dir = temp_run_dir("loop-start");
3534 let _ = fs::remove_dir_all(&run_dir);
3535 fs::create_dir_all(run_dir.join("blobs")).unwrap();
3536 let paths = crate::BeamPaths::from_root(run_dir.clone());
3537 let run_id = "run-loop-start";
3538 crate::bootstrap_workflow_run(
3539 &paths,
3540 crate::BootstrapWorkflowRunInput {
3541 run_id,
3542 workflow_json: &min_workflow_json("flow-loop-start", "a"),
3543 expected_workflow_id: Some("flow-loop-start"),
3544 params: &BTreeMap::new(),
3545 initiator: "cli",
3546 chat_binding: None,
3547 },
3548 )
3549 .unwrap();
3550
3551 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3552 let action = OrchestratorAction::StartLoop {
3553 node_id: "loop-1".to_string(),
3554 max_iterations: 5,
3555 };
3556 start_loop(&mut log, &action).await.unwrap();
3557
3558 let events = log.read_all().unwrap();
3559 let loop_events: Vec<_> = events
3560 .iter()
3561 .filter(|e| e.event_type == "loopStarted")
3562 .collect();
3563 assert_eq!(
3564 loop_events.len(),
3565 1,
3566 "expected exactly one loopStarted event"
3567 );
3568 let ev = loop_events[0];
3569 assert_eq!(ev.payload["loopId"], "loop-1");
3570 assert_eq!(ev.payload["maxIterations"], 5);
3571 assert_eq!(ev.actor, WorkflowActor::Scheduler);
3572
3573 let _ = fs::remove_dir_all(&run_dir);
3574 }
3575
3576 #[tokio::test]
3577 async fn start_loop_iteration_writes_event() {
3578 let run_dir = temp_run_dir("loop-iter-start");
3579 let _ = fs::remove_dir_all(&run_dir);
3580 fs::create_dir_all(run_dir.join("blobs")).unwrap();
3581 let paths = crate::BeamPaths::from_root(run_dir.clone());
3582 let run_id = "run-loop-iter-start";
3583 crate::bootstrap_workflow_run(
3584 &paths,
3585 crate::BootstrapWorkflowRunInput {
3586 run_id,
3587 workflow_json: &min_workflow_json("flow-loop-iter-start", "a"),
3588 expected_workflow_id: Some("flow-loop-iter-start"),
3589 params: &BTreeMap::new(),
3590 initiator: "cli",
3591 chat_binding: None,
3592 },
3593 )
3594 .unwrap();
3595
3596 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3597 let action = OrchestratorAction::StartLoopIteration {
3598 node_id: "loop-1".to_string(),
3599 iteration: 2,
3600 };
3601 start_loop_iteration(&mut log, &action).await.unwrap();
3602
3603 let events = log.read_all().unwrap();
3604 let loop_events: Vec<_> = events
3605 .iter()
3606 .filter(|e| e.event_type == "loopIterationStarted")
3607 .collect();
3608 assert_eq!(
3609 loop_events.len(),
3610 1,
3611 "expected exactly one loopIterationStarted event"
3612 );
3613 let ev = loop_events[0];
3614 assert_eq!(ev.payload["loopId"], "loop-1");
3615 assert_eq!(ev.payload["iteration"], 2);
3616 assert_eq!(ev.actor, WorkflowActor::Scheduler);
3617
3618 let _ = fs::remove_dir_all(&run_dir);
3619 }
3620
3621 #[tokio::test]
3622 async fn finish_loop_iteration_writes_event() {
3623 let run_dir = temp_run_dir("loop-iter-finish");
3624 let _ = fs::remove_dir_all(&run_dir);
3625 fs::create_dir_all(run_dir.join("blobs")).unwrap();
3626 let paths = crate::BeamPaths::from_root(run_dir.clone());
3627 let run_id = "run-loop-iter-finish";
3628 crate::bootstrap_workflow_run(
3629 &paths,
3630 crate::BootstrapWorkflowRunInput {
3631 run_id,
3632 workflow_json: &min_workflow_json("flow-loop-iter-finish", "a"),
3633 expected_workflow_id: Some("flow-loop-iter-finish"),
3634 params: &BTreeMap::new(),
3635 initiator: "cli",
3636 chat_binding: None,
3637 },
3638 )
3639 .unwrap();
3640
3641 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3642 let action = OrchestratorAction::FinishLoopIteration {
3643 node_id: "loop-1".to_string(),
3644 iteration: 3,
3645 resolution: "approved".to_string(),
3646 decision_activity_id: Some("run-loop-iter-finish::gate::loop-1".to_string()),
3647 wait_resolved_event_id: None,
3648 by: Some("tester".to_string()),
3649 comment: Some("looks good".to_string()),
3650 timed_out: Some(false),
3651 };
3652 finish_loop_iteration(&mut log, &action).await.unwrap();
3653
3654 let events = log.read_all().unwrap();
3655 let loop_events: Vec<_> = events
3656 .iter()
3657 .filter(|e| e.event_type == "loopIterationFinished")
3658 .collect();
3659 assert_eq!(
3660 loop_events.len(),
3661 1,
3662 "expected exactly one loopIterationFinished event"
3663 );
3664 let ev = loop_events[0];
3665 assert_eq!(ev.payload["loopId"], "loop-1");
3666 assert_eq!(ev.payload["iteration"], 3);
3667 assert_eq!(ev.payload["resolution"], "approved");
3668 assert_eq!(ev.payload["by"], "tester");
3669 assert_eq!(ev.payload["comment"], "looks good");
3670 assert_eq!(ev.payload["timedOut"], false);
3671 assert_eq!(ev.actor, WorkflowActor::Scheduler);
3672
3673 let _ = fs::remove_dir_all(&run_dir);
3674 }
3675
3676 #[tokio::test]
3677 async fn finish_loop_writes_loop_finished_event() {
3678 let run_dir = temp_run_dir("loop-finish");
3679 let _ = fs::remove_dir_all(&run_dir);
3680 fs::create_dir_all(run_dir.join("blobs")).unwrap();
3681 let paths = crate::BeamPaths::from_root(run_dir.clone());
3682 let run_id = "run-loop-finish";
3683 crate::bootstrap_workflow_run(
3684 &paths,
3685 crate::BootstrapWorkflowRunInput {
3686 run_id,
3687 workflow_json: &min_workflow_json("flow-loop-finish", "a"),
3688 expected_workflow_id: Some("flow-loop-finish"),
3689 params: &BTreeMap::new(),
3690 initiator: "cli",
3691 chat_binding: None,
3692 },
3693 )
3694 .unwrap();
3695
3696 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3697 let output_ref = WorkflowOutputRef {
3698 output_hash: "sha256:abc".to_string(),
3699 output_path: "/tmp/loop-out.json".to_string(),
3700 output_bytes: 10,
3701 output_schema_version: 1,
3702 content_type: Some("application/json".to_string()),
3703 };
3704 let action = OrchestratorAction::FinishLoop {
3705 node_id: "loop-1".to_string(),
3706 final_iteration: 3,
3707 resolution: "approved".to_string(),
3708 output_ref: Some(output_ref.clone()),
3709 error_code: None,
3710 error_class: None,
3711 };
3712 finish_loop(&mut log, &action).await.unwrap();
3713
3714 let events = log.read_all().unwrap();
3715 let loop_events: Vec<_> = events
3716 .iter()
3717 .filter(|e| e.event_type == "loopFinished")
3718 .collect();
3719 assert_eq!(
3720 loop_events.len(),
3721 1,
3722 "expected exactly one loopFinished event"
3723 );
3724 let ev = loop_events[0];
3725 assert_eq!(ev.payload["loopId"], "loop-1");
3726 assert_eq!(ev.payload["finalIteration"], 3);
3727 assert_eq!(ev.payload["resolution"], "approved");
3728 assert_eq!(ev.actor, WorkflowActor::Scheduler);
3729
3730 let _ = fs::remove_dir_all(&run_dir);
3731 }
3732
3733 #[tokio::test]
3734 async fn all_loop_actions_produce_correct_event_sequence() {
3735 let run_dir = temp_run_dir("loop-sequence");
3737 let _ = fs::remove_dir_all(&run_dir);
3738 fs::create_dir_all(run_dir.join("blobs")).unwrap();
3739 let paths = crate::BeamPaths::from_root(run_dir.clone());
3740 let run_id = "run-loop-seq";
3741 crate::bootstrap_workflow_run(
3742 &paths,
3743 crate::BootstrapWorkflowRunInput {
3744 run_id,
3745 workflow_json: &min_workflow_json("flow-loop-seq", "a"),
3746 expected_workflow_id: Some("flow-loop-seq"),
3747 params: &BTreeMap::new(),
3748 initiator: "cli",
3749 chat_binding: None,
3750 },
3751 )
3752 .unwrap();
3753
3754 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3755
3756 start_loop(
3758 &mut log,
3759 &OrchestratorAction::StartLoop {
3760 node_id: "loop-1".to_string(),
3761 max_iterations: 3,
3762 },
3763 )
3764 .await
3765 .unwrap();
3766
3767 start_loop_iteration(
3769 &mut log,
3770 &OrchestratorAction::StartLoopIteration {
3771 node_id: "loop-1".to_string(),
3772 iteration: 1,
3773 },
3774 )
3775 .await
3776 .unwrap();
3777 finish_loop_iteration(
3778 &mut log,
3779 &OrchestratorAction::FinishLoopIteration {
3780 node_id: "loop-1".to_string(),
3781 iteration: 1,
3782 resolution: "approved".to_string(),
3783 decision_activity_id: None,
3784 wait_resolved_event_id: None,
3785 by: None,
3786 comment: None,
3787 timed_out: None,
3788 },
3789 )
3790 .await
3791 .unwrap();
3792
3793 start_loop_iteration(
3795 &mut log,
3796 &OrchestratorAction::StartLoopIteration {
3797 node_id: "loop-1".to_string(),
3798 iteration: 2,
3799 },
3800 )
3801 .await
3802 .unwrap();
3803 finish_loop_iteration(
3804 &mut log,
3805 &OrchestratorAction::FinishLoopIteration {
3806 node_id: "loop-1".to_string(),
3807 iteration: 2,
3808 resolution: "rejected".to_string(),
3809 decision_activity_id: Some(format!("{}::gate::loop-1", run_id)),
3810 wait_resolved_event_id: None,
3811 by: Some("reviewer".to_string()),
3812 comment: Some("needs work".to_string()),
3813 timed_out: Some(false),
3814 },
3815 )
3816 .await
3817 .unwrap();
3818
3819 let output_ref = WorkflowOutputRef {
3821 output_hash: "sha256:xyz".to_string(),
3822 output_path: "/tmp/xyz.json".to_string(),
3823 output_bytes: 8,
3824 output_schema_version: 1,
3825 content_type: Some("application/json".to_string()),
3826 };
3827 finish_loop(
3828 &mut log,
3829 &OrchestratorAction::FinishLoop {
3830 node_id: "loop-1".to_string(),
3831 final_iteration: 2,
3832 resolution: "approved".to_string(),
3833 output_ref: Some(output_ref.clone()),
3834 error_code: None,
3835 error_class: None,
3836 },
3837 )
3838 .await
3839 .unwrap();
3840
3841 let events = log.read_all().unwrap();
3842 let loop_event_types: Vec<_> = events
3843 .iter()
3844 .filter(|e| e.event_type.starts_with("loop"))
3845 .map(|e| e.event_type.as_str())
3846 .collect();
3847 assert_eq!(
3848 loop_event_types,
3849 vec![
3850 "loopStarted",
3851 "loopIterationStarted",
3852 "loopIterationFinished",
3853 "loopIterationStarted",
3854 "loopIterationFinished",
3855 "loopFinished",
3856 ],
3857 "expected full loop lifecycle events in order"
3858 );
3859
3860 let _ = fs::remove_dir_all(&run_dir);
3861 }
3862
3863 #[tokio::test]
3864 async fn replay_builds_snapshot_loops_from_loop_events() {
3865 let run_dir = temp_run_dir("loop-replay");
3868 let _ = fs::remove_dir_all(&run_dir);
3869 fs::create_dir_all(run_dir.join("blobs")).unwrap();
3870 let paths = crate::BeamPaths::from_root(run_dir.clone());
3871 let run_id = "run-loop-replay";
3872 crate::bootstrap_workflow_run(
3873 &paths,
3874 crate::BootstrapWorkflowRunInput {
3875 run_id,
3876 workflow_json: &min_workflow_json("flow-loop-replay", "a"),
3877 expected_workflow_id: Some("flow-loop-replay"),
3878 params: &BTreeMap::new(),
3879 initiator: "cli",
3880 chat_binding: None,
3881 },
3882 )
3883 .unwrap();
3884
3885 {
3887 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
3888 let _ = log
3889 .append(EventDraft {
3890 event_type: "loopStarted".to_string(),
3891 actor: WorkflowActor::Scheduler,
3892 payload: serde_json::json!({
3893 "loopId": "loop-1",
3894 "maxIterations": 3,
3895 }),
3896 timestamp: None,
3897 payload_hash: None,
3898 })
3899 .unwrap();
3900 let _ = log
3901 .append(EventDraft {
3902 event_type: "loopIterationStarted".to_string(),
3903 actor: WorkflowActor::Scheduler,
3904 payload: serde_json::json!({
3905 "loopId": "loop-1",
3906 "iteration": 1,
3907 }),
3908 timestamp: None,
3909 payload_hash: None,
3910 })
3911 .unwrap();
3912 let _ = log
3913 .append(EventDraft {
3914 event_type: "loopIterationFinished".to_string(),
3915 actor: WorkflowActor::Scheduler,
3916 payload: serde_json::json!({
3917 "loopId": "loop-1",
3918 "iteration": 1,
3919 "resolution": "approved",
3920 }),
3921 timestamp: None,
3922 payload_hash: None,
3923 })
3924 .unwrap();
3925 let _ = log
3926 .append(EventDraft {
3927 event_type: "loopFinished".to_string(),
3928 actor: WorkflowActor::Scheduler,
3929 payload: serde_json::json!({
3930 "loopId": "loop-1",
3931 "finalIteration": 1,
3932 "resolution": "approved",
3933 }),
3934 timestamp: None,
3935 payload_hash: None,
3936 })
3937 .unwrap();
3938 }
3939
3940 let rt = WorkflowRuntimeContext {
3942 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
3943 def: WorkflowDefinition {
3944 workflow_id: "flow-loop-replay".to_string(),
3945 version: 1,
3946 params: None,
3947 defaults: None,
3948 nodes: BTreeMap::new(),
3949 },
3950 runs_base_dir: paths.workflow_runs_dir(),
3951 };
3952 let snapshot = read_snapshot(&rt).await.unwrap();
3953
3954 let loops = snapshot
3955 .loops
3956 .as_ref()
3957 .expect("snapshot.loops should be Some after loop events");
3958 assert!(
3959 loops.contains_key("loop-1"),
3960 "loop-1 should be in snapshot.loops"
3961 );
3962
3963 let loop_state = loops.get("loop-1").unwrap();
3964 assert_eq!(loop_state.loop_id, "loop-1");
3965 assert_eq!(
3966 loop_state.status,
3967 LoopStatus::Succeeded,
3968 "loop should be Succeeded (approved)"
3969 );
3970 assert_eq!(loop_state.max_iterations, 3);
3971 assert_eq!(loop_state.iteration, 1, "finalIteration should be 1");
3972 assert_eq!(
3973 loop_state.iterations.len(),
3974 1,
3975 "should have 1 iteration recorded"
3976 );
3977
3978 let iteration = &loop_state.iterations[0];
3979 assert_eq!(iteration.iteration, 1);
3980 assert_eq!(iteration.status, LoopIterationStatus::Approved);
3981
3982 let _ = fs::remove_dir_all(&run_dir);
3983 }
3984
3985 #[tokio::test]
3986 async fn replay_loop_failed_sets_status_and_dangling_iteration() {
3987 let run_dir = temp_run_dir("loop-failed-replay");
3991 let _ = fs::remove_dir_all(&run_dir);
3992 fs::create_dir_all(run_dir.join("blobs")).unwrap();
3993 let paths = crate::BeamPaths::from_root(run_dir.clone());
3994 let run_id = "run-loop-failed-replay";
3995 crate::bootstrap_workflow_run(
3996 &paths,
3997 crate::BootstrapWorkflowRunInput {
3998 run_id,
3999 workflow_json: &min_workflow_json("flow-loop-failed-replay", "a"),
4000 expected_workflow_id: Some("flow-loop-failed-replay"),
4001 params: &BTreeMap::new(),
4002 initiator: "cli",
4003 chat_binding: None,
4004 },
4005 )
4006 .unwrap();
4007
4008 {
4009 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4010 let _ = log
4011 .append(EventDraft {
4012 event_type: "loopStarted".to_string(),
4013 actor: WorkflowActor::Scheduler,
4014 payload: serde_json::json!({
4015 "loopId": "loop-1",
4016 "maxIterations": 5,
4017 }),
4018 timestamp: None,
4019 payload_hash: None,
4020 })
4021 .unwrap();
4022 let _ = log
4023 .append(EventDraft {
4024 event_type: "loopIterationStarted".to_string(),
4025 actor: WorkflowActor::Scheduler,
4026 payload: serde_json::json!({
4027 "loopId": "loop-1",
4028 "iteration": 1,
4029 }),
4030 timestamp: None,
4031 payload_hash: None,
4032 })
4033 .unwrap();
4034 let _ = log
4035 .append(EventDraft {
4036 event_type: "loopIterationFinished".to_string(),
4037 actor: WorkflowActor::Scheduler,
4038 payload: serde_json::json!({
4039 "loopId": "loop-1",
4040 "iteration": 1,
4041 "resolution": "approved",
4042 }),
4043 timestamp: None,
4044 payload_hash: None,
4045 })
4046 .unwrap();
4047 let _ = log
4048 .append(EventDraft {
4049 event_type: "loopIterationStarted".to_string(),
4050 actor: WorkflowActor::Scheduler,
4051 payload: serde_json::json!({
4052 "loopId": "loop-1",
4053 "iteration": 2,
4054 }),
4055 timestamp: None,
4056 payload_hash: None,
4057 })
4058 .unwrap();
4059 let _ = log
4061 .append(EventDraft {
4062 event_type: "loopFinished".to_string(),
4063 actor: WorkflowActor::Scheduler,
4064 payload: serde_json::json!({
4065 "loopId": "loop-1",
4066 "finalIteration": 2,
4067 "resolution": "failed",
4068 "errorCode": "LoopFailedMidIteration",
4069 "errorClass": "fatal",
4070 }),
4071 timestamp: None,
4072 payload_hash: None,
4073 })
4074 .unwrap();
4075 }
4076
4077 let rt = WorkflowRuntimeContext {
4078 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4079 def: WorkflowDefinition {
4080 workflow_id: "flow-loop-failed-replay".to_string(),
4081 version: 1,
4082 params: None,
4083 defaults: None,
4084 nodes: BTreeMap::new(),
4085 },
4086 runs_base_dir: paths.workflow_runs_dir(),
4087 };
4088 let snapshot = read_snapshot(&rt).await.unwrap();
4089
4090 let loops = snapshot
4091 .loops
4092 .as_ref()
4093 .expect("snapshot.loops should be Some after loop events");
4094 let loop_state = loops.get("loop-1").unwrap();
4095 assert_eq!(
4096 loop_state.status,
4097 LoopStatus::Failed,
4098 "loop should be Failed"
4099 );
4100 assert_eq!(loop_state.error_class.as_deref(), Some("fatal"));
4101 assert_eq!(
4102 loop_state.error_code.as_deref(),
4103 Some("LoopFailedMidIteration")
4104 );
4105 assert_eq!(loop_state.iteration, 2);
4106 assert_eq!(loop_state.iterations.len(), 2);
4107
4108 let iter1 = loop_state
4110 .iterations
4111 .iter()
4112 .find(|it| it.iteration == 1)
4113 .unwrap();
4114 assert_eq!(iter1.status, LoopIterationStatus::Approved);
4115
4116 let iter2 = loop_state
4118 .iterations
4119 .iter()
4120 .find(|it| it.iteration == 2)
4121 .unwrap();
4122 assert_eq!(
4123 iter2.status,
4124 LoopIterationStatus::Failed,
4125 "inflight iteration should be Failed"
4126 );
4127
4128 let _ = fs::remove_dir_all(&run_dir);
4129 }
4130
4131 fn code_review_loop_def() -> WorkflowDefinition {
4135 WorkflowDefinition {
4136 workflow_id: "code-review-loop".to_string(),
4137 version: 1,
4138 params: None,
4139 defaults: None,
4140 nodes: BTreeMap::from([
4141 (
4142 "implement".to_string(),
4143 WorkflowNode::Subagent(SubagentNode {
4144 base: NodeBase {
4145 description: None,
4146 depends: None,
4147 human_gate: None,
4148 retry_policy: None,
4149 timeout_ms: None,
4150 max_output_bytes: None,
4151 output_schema: None,
4152 unsafe_allow_ungated: None,
4153 },
4154 bot: "bot-a".to_string(),
4155 prompt: Value::String("implement".to_string()),
4156 working_dir: None,
4157 model_overrides: None,
4158 tool_policy: None,
4159 }),
4160 ),
4161 (
4162 "review".to_string(),
4163 WorkflowNode::Subagent(SubagentNode {
4164 base: NodeBase {
4165 description: None,
4166 depends: Some(vec!["implement".to_string()]),
4167 human_gate: None,
4168 retry_policy: None,
4169 timeout_ms: None,
4170 max_output_bytes: None,
4171 output_schema: None,
4172 unsafe_allow_ungated: None,
4173 },
4174 bot: "bot-a".to_string(),
4175 prompt: Value::String("review".to_string()),
4176 working_dir: None,
4177 model_overrides: None,
4178 tool_policy: None,
4179 }),
4180 ),
4181 (
4182 "reviewDecision".to_string(),
4183 WorkflowNode::Decision(DecisionNode {
4184 base: NodeBase {
4185 description: None,
4186 depends: Some(vec!["review".to_string()]),
4187 human_gate: Some(HumanGate {
4188 stage: "before".to_string(),
4189 prompt: Value::String("approve?".to_string()),
4190 approvers: None,
4191 deadline_ms: None,
4192 on_timeout: None,
4193 }),
4194 retry_policy: None,
4195 timeout_ms: None,
4196 max_output_bytes: None,
4197 output_schema: None,
4198 unsafe_allow_ungated: None,
4199 },
4200 }),
4201 ),
4202 (
4203 "review-loop".to_string(),
4204 WorkflowNode::Loop(LoopNode {
4205 base: NodeBase {
4206 description: None,
4207 depends: None,
4208 human_gate: None,
4209 retry_policy: None,
4210 timeout_ms: None,
4211 max_output_bytes: None,
4212 output_schema: None,
4213 unsafe_allow_ungated: None,
4214 },
4215 max_iterations: 3,
4216 body: vec![
4217 "implement".to_string(),
4218 "review".to_string(),
4219 "reviewDecision".to_string(),
4220 ],
4221 terminate: LoopTerminate {
4222 node: "reviewDecision".to_string(),
4223 via: "humanGate".to_string(),
4224 },
4225 output: Some(LoopOutputProjection {
4226 from: "implement".to_string(),
4227 }),
4228 }),
4229 ),
4230 ]),
4231 }
4232 }
4233
4234 #[tokio::test]
4235 async fn loop_depends_met_produces_start_loop_and_first_iteration() {
4236 let run_dir = temp_run_dir("loop-depends");
4239 let _ = fs::remove_dir_all(&run_dir);
4240 fs::create_dir_all(run_dir.join("blobs")).unwrap();
4241 let paths = crate::BeamPaths::from_root(run_dir.clone());
4242 let params = BTreeMap::new();
4243 let run_id = "run-loop-depends";
4244 crate::bootstrap_workflow_run(
4245 &paths,
4246 crate::BootstrapWorkflowRunInput {
4247 run_id,
4248 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"}}}}"#,
4249 expected_workflow_id: Some("flow-loop-dep"),
4250 params: ¶ms,
4251 initiator: "cli",
4252 chat_binding: None,
4253 },
4254 )
4255 .unwrap();
4256
4257 {
4259 let mut rt = WorkflowRuntimeContext {
4260 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4261 def: WorkflowDefinition {
4262 workflow_id: "flow-loop-dep".to_string(),
4263 version: 1,
4264 params: None,
4265 defaults: None,
4266 nodes: BTreeMap::from([
4267 (
4268 "pre".to_string(),
4269 WorkflowNode::Subagent(SubagentNode {
4270 base: NodeBase {
4271 description: None,
4272 depends: None,
4273 human_gate: None,
4274 retry_policy: None,
4275 timeout_ms: None,
4276 max_output_bytes: None,
4277 output_schema: None,
4278 unsafe_allow_ungated: None,
4279 },
4280 bot: "bot-x".to_string(),
4281 prompt: Value::String("setup".to_string()),
4282 working_dir: None,
4283 model_overrides: None,
4284 tool_policy: None,
4285 }),
4286 ),
4287 (
4288 "dec".to_string(),
4289 WorkflowNode::Decision(DecisionNode {
4290 base: NodeBase {
4291 description: None,
4292 depends: None,
4293 human_gate: None,
4294 retry_policy: None,
4295 timeout_ms: None,
4296 max_output_bytes: None,
4297 output_schema: None,
4298 unsafe_allow_ungated: None,
4299 },
4300 }),
4301 ),
4302 (
4303 "rl".to_string(),
4304 WorkflowNode::Loop(LoopNode {
4305 base: NodeBase {
4306 description: None,
4307 depends: Some(vec!["pre".to_string()]),
4308 human_gate: None,
4309 retry_policy: None,
4310 timeout_ms: None,
4311 max_output_bytes: None,
4312 output_schema: None,
4313 unsafe_allow_ungated: None,
4314 },
4315 max_iterations: 3,
4316 body: vec!["dec".to_string()],
4317 terminate: LoopTerminate {
4318 node: "dec".to_string(),
4319 via: "humanGate".to_string(),
4320 },
4321 output: Some(LoopOutputProjection {
4322 from: "dec".to_string(),
4323 }),
4324 }),
4325 ),
4326 ]),
4327 },
4328 runs_base_dir: paths.workflow_runs_dir(),
4329 };
4330 let mut hooks = FakeHooks;
4331 let _result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
4332 assert!(
4333 _result.ticks > 0 || matches!(_result.reason, RunLoopStopReason::Terminal),
4334 "expected pre dispatch; reason={:?}",
4335 _result.reason
4336 );
4337 }
4338
4339 {
4341 let mut rt = WorkflowRuntimeContext {
4342 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4343 def: WorkflowDefinition {
4344 workflow_id: "flow-loop-dep".to_string(),
4345 version: 1,
4346 params: None,
4347 defaults: None,
4348 nodes: BTreeMap::from([
4349 (
4350 "pre".to_string(),
4351 WorkflowNode::Subagent(SubagentNode {
4352 base: NodeBase {
4353 description: None,
4354 depends: None,
4355 human_gate: None,
4356 retry_policy: None,
4357 timeout_ms: None,
4358 max_output_bytes: None,
4359 output_schema: None,
4360 unsafe_allow_ungated: None,
4361 },
4362 bot: "bot-x".to_string(),
4363 prompt: Value::String("setup".to_string()),
4364 working_dir: None,
4365 model_overrides: None,
4366 tool_policy: None,
4367 }),
4368 ),
4369 (
4370 "dec".to_string(),
4371 WorkflowNode::Decision(DecisionNode {
4372 base: NodeBase {
4373 description: None,
4374 depends: None,
4375 human_gate: None,
4376 retry_policy: None,
4377 timeout_ms: None,
4378 max_output_bytes: None,
4379 output_schema: None,
4380 unsafe_allow_ungated: None,
4381 },
4382 }),
4383 ),
4384 (
4385 "rl".to_string(),
4386 WorkflowNode::Loop(LoopNode {
4387 base: NodeBase {
4388 description: None,
4389 depends: Some(vec!["pre".to_string()]),
4390 human_gate: None,
4391 retry_policy: None,
4392 timeout_ms: None,
4393 max_output_bytes: None,
4394 output_schema: None,
4395 unsafe_allow_ungated: None,
4396 },
4397 max_iterations: 3,
4398 body: vec!["dec".to_string()],
4399 terminate: LoopTerminate {
4400 node: "dec".to_string(),
4401 via: "humanGate".to_string(),
4402 },
4403 output: Some(LoopOutputProjection {
4404 from: "dec".to_string(),
4405 }),
4406 }),
4407 ),
4408 ]),
4409 },
4410 runs_base_dir: paths.workflow_runs_dir(),
4411 };
4412 let mut hooks = FakeHooks;
4413 let _result = run_loop(&mut rt, &mut hooks, 5, 1).await.unwrap();
4414
4415 let events = rt.log.read_all().unwrap();
4416 let loop_started = events.iter().any(|e| {
4417 e.event_type == "loopStarted"
4418 && e.payload.get("loopId").and_then(Value::as_str) == Some("rl")
4419 });
4420 let iter_started = events.iter().any(|e| {
4421 e.event_type == "loopIterationStarted"
4422 && e.payload.get("loopId").and_then(Value::as_str) == Some("rl")
4423 && e.payload.get("iteration").and_then(Value::as_u64) == Some(1)
4424 });
4425 assert!(loop_started, "expected loopStarted event for rl");
4426 assert!(iter_started, "expected loopIterationStarted with iteration 1 for rl");
4427 }
4428
4429 let _ = fs::remove_dir_all(&run_dir);
4430 }
4431
4432 #[tokio::test]
4433 async fn code_review_loop_reaches_human_gate_wait_with_correct_activity_id() {
4434 let run_dir = temp_run_dir("crl-gate");
4437 let _ = fs::remove_dir_all(&run_dir);
4438 fs::create_dir_all(run_dir.join("blobs")).unwrap();
4439 let paths = crate::BeamPaths::from_root(run_dir.clone());
4440 let run_id = "run-crl-gate";
4441 let def = code_review_loop_def();
4442 let workflow_json = serde_json::to_string(&def).unwrap();
4443 crate::bootstrap_workflow_run(
4444 &paths,
4445 crate::BootstrapWorkflowRunInput {
4446 run_id,
4447 workflow_json: &workflow_json,
4448 expected_workflow_id: Some("code-review-loop"),
4449 params: &BTreeMap::new(),
4450 initiator: "cli",
4451 chat_binding: Some(RunChatBinding {
4452 chat_id: "chat-1".to_string(),
4453 lark_app_id: "app-1".to_string(),
4454 }),
4455 },
4456 )
4457 .unwrap();
4458
4459 let mut rt = WorkflowRuntimeContext {
4460 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4461 def,
4462 runs_base_dir: paths.workflow_runs_dir(),
4463 };
4464 let mut hooks = FakeHooks;
4465 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4466
4467 assert_eq!(
4469 result.reason,
4470 RunLoopStopReason::AwaitingWait,
4471 "expected AwaitingWait for open human gate"
4472 );
4473
4474 let events = rt.log.read_all().unwrap();
4475
4476 let loop_started = events.iter().find(|e| e.event_type == "loopStarted");
4478 assert!(loop_started.is_some(), "missing loopStarted");
4479 let iter_started = events
4480 .iter()
4481 .find(|e| e.event_type == "loopIterationStarted" && e.payload.get("iteration") == Some(&Value::Number(1.into())));
4482 assert!(iter_started.is_some(), "missing loopIterationStarted(1)");
4483
4484 let implement_work_id =
4486 format!("{}::loop::review-loop.1::work::implement", run_id);
4487 let review_work_id = format!("{}::loop::review-loop.1::work::review", run_id);
4488 let decision_gate_id =
4489 format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
4490
4491 let has_implement = events.iter().any(|e| {
4492 e.event_type == "attemptCreated"
4493 && e.payload.get("activityId").and_then(Value::as_str)
4494 == Some(&implement_work_id)
4495 });
4496 let has_review = events.iter().any(|e| {
4497 e.event_type == "attemptCreated"
4498 && e.payload.get("activityId").and_then(Value::as_str)
4499 == Some(&review_work_id)
4500 });
4501 let has_decision_gate = events.iter().any(|e| {
4502 e.event_type == "waitCreated"
4503 && e.payload.get("activityId").and_then(Value::as_str)
4504 == Some(&decision_gate_id)
4505 });
4506
4507 assert!(has_implement, "missing implement work dispatch: {}", implement_work_id);
4508 assert!(has_review, "missing review work dispatch: {}", review_work_id);
4509 assert!(
4510 has_decision_gate,
4511 "missing reviewDecision gate wait: {}",
4512 decision_gate_id
4513 );
4514
4515 let _ = fs::remove_dir_all(&run_dir);
4516 }
4517
4518 #[tokio::test]
4519 async fn reject_decision_enters_next_iteration() {
4520 let run_dir = temp_run_dir("crl-reject");
4523 let _ = fs::remove_dir_all(&run_dir);
4524 fs::create_dir_all(run_dir.join("blobs")).unwrap();
4525 let paths = crate::BeamPaths::from_root(run_dir.clone());
4526 let run_id = "run-crl-reject";
4527 let def = code_review_loop_def();
4528 let workflow_json = serde_json::to_string(&def).unwrap();
4529 crate::bootstrap_workflow_run(
4530 &paths,
4531 crate::BootstrapWorkflowRunInput {
4532 run_id,
4533 workflow_json: &workflow_json,
4534 expected_workflow_id: Some("code-review-loop"),
4535 params: &BTreeMap::new(),
4536 initiator: "cli",
4537 chat_binding: Some(RunChatBinding {
4538 chat_id: "chat-1".to_string(),
4539 lark_app_id: "app-1".to_string(),
4540 }),
4541 },
4542 )
4543 .unwrap();
4544
4545 let decision_gate_id =
4546 format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
4547
4548 {
4550 let mut rt = WorkflowRuntimeContext {
4551 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4552 def: def.clone(),
4553 runs_base_dir: paths.workflow_runs_dir(),
4554 };
4555 let mut hooks = FakeHooks;
4556 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4557 assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4558 }
4559
4560 {
4562 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4563 let _ = log
4564 .append(EventDraft {
4565 event_type: "waitResolved".to_string(),
4566 actor: WorkflowActor::Human,
4567 payload: serde_json::json!({
4568 "activityId": &decision_gate_id,
4569 "resolution": "rejected",
4570 "by": "reviewer",
4571 "comment": "needs work",
4572 }),
4573 timestamp: None,
4574 payload_hash: None,
4575 })
4576 .unwrap();
4577 }
4578
4579 {
4581 let mut rt = WorkflowRuntimeContext {
4582 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4583 def,
4584 runs_base_dir: paths.workflow_runs_dir(),
4585 };
4586 let mut hooks = FakeHooks;
4587 let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4588
4589 let events = rt.log.read_all().unwrap();
4590
4591 let iter1_finished = events.iter().any(|e| {
4593 e.event_type == "loopIterationFinished"
4594 && e.payload.get("iteration").and_then(Value::as_u64) == Some(1)
4595 && e.payload.get("resolution").and_then(Value::as_str) == Some("rejected")
4596 });
4597 assert!(iter1_finished, "expected loopIterationFinished with resolution=rejected for iteration 1");
4598
4599 let iter2_started = events.iter().any(|e| {
4601 e.event_type == "loopIterationStarted"
4602 && e.payload.get("iteration").and_then(Value::as_u64) == Some(2)
4603 });
4604 assert!(iter2_started, "expected loopIterationStarted with iteration=2");
4605
4606 let implement_work_v2 =
4608 format!("{}::loop::review-loop.2::work::implement", run_id);
4609 let has_iter2_implement = events.iter().any(|e| {
4610 e.event_type == "attemptCreated"
4611 && e.payload.get("activityId").and_then(Value::as_str)
4612 == Some(&implement_work_v2)
4613 });
4614 assert!(
4615 has_iter2_implement,
4616 "expected iteration 2 to dispatch implement work: {}",
4617 implement_work_v2
4618 );
4619 }
4620
4621 let _ = fs::remove_dir_all(&run_dir);
4622 }
4623
4624 #[tokio::test]
4625 async fn approve_decision_finishes_loop_and_run_succeeds() {
4626 let run_dir = temp_run_dir("crl-approve");
4629 let _ = fs::remove_dir_all(&run_dir);
4630 fs::create_dir_all(run_dir.join("blobs")).unwrap();
4631 let paths = crate::BeamPaths::from_root(run_dir.clone());
4632 let run_id = "run-crl-approve";
4633 let def = code_review_loop_def();
4634 let workflow_json = serde_json::to_string(&def).unwrap();
4635 crate::bootstrap_workflow_run(
4636 &paths,
4637 crate::BootstrapWorkflowRunInput {
4638 run_id,
4639 workflow_json: &workflow_json,
4640 expected_workflow_id: Some("code-review-loop"),
4641 params: &BTreeMap::new(),
4642 initiator: "cli",
4643 chat_binding: Some(RunChatBinding {
4644 chat_id: "chat-1".to_string(),
4645 lark_app_id: "app-1".to_string(),
4646 }),
4647 },
4648 )
4649 .unwrap();
4650
4651 let decision_gate_id =
4652 format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
4653
4654 {
4656 let mut rt = WorkflowRuntimeContext {
4657 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4658 def: def.clone(),
4659 runs_base_dir: paths.workflow_runs_dir(),
4660 };
4661 let mut hooks = FakeHooks;
4662 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4663 assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4664 }
4665
4666 {
4668 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4669 let _ = log
4670 .append(EventDraft {
4671 event_type: "waitResolved".to_string(),
4672 actor: WorkflowActor::Human,
4673 payload: serde_json::json!({
4674 "activityId": &decision_gate_id,
4675 "resolution": "approved",
4676 "by": "reviewer",
4677 "comment": "lgtm",
4678 }),
4679 timestamp: None,
4680 payload_hash: None,
4681 })
4682 .unwrap();
4683 }
4684
4685 {
4687 let mut rt = WorkflowRuntimeContext {
4688 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4689 def,
4690 runs_base_dir: paths.workflow_runs_dir(),
4691 };
4692 let mut hooks = FakeHooks;
4693 let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4694
4695 let events = rt.log.read_all().unwrap();
4696
4697 let loop_finished = events.iter().any(|e| {
4699 e.event_type == "loopFinished"
4700 && e.payload.get("loopId").and_then(Value::as_str) == Some("review-loop")
4701 && e.payload.get("resolution").and_then(Value::as_str) == Some("approved")
4702 });
4703 assert!(
4704 loop_finished,
4705 "expected loopFinished with resolution=approved"
4706 );
4707
4708 let run_succeeded = events.iter().any(|e| e.event_type == "runSucceeded");
4710 assert!(run_succeeded, "expected run to succeed after loop approval");
4711
4712 let snap = read_snapshot(&rt).await.unwrap();
4714 let loop_output_key = format!("{}::work::review-loop", run_id);
4715 assert!(
4716 snap.outputs.contains_key(&loop_output_key),
4717 "expected loop output under {}",
4718 loop_output_key
4719 );
4720 }
4721
4722 let _ = fs::remove_dir_all(&run_dir);
4723 }
4724
4725 #[derive(Clone)]
4727 struct FailingBodyHooks;
4728
4729 #[async_trait]
4730 impl WorkflowExecutionHooks for FailingBodyHooks {
4731 async fn execute_subagent(
4732 &mut self,
4733 _ctx: WorkflowDispatchRun<'_>,
4734 _node: &SubagentNode,
4735 _resolved_prompt: String,
4736 ) -> Result<WorkflowDispatchOutcome> {
4737 Ok(WorkflowDispatchOutcome::Failed {
4738 error_code: "TestFailure".to_string(),
4739 error_class: "fatal".to_string(),
4740 error_message: "simulated body failure".to_string(),
4741 session: None,
4742 })
4743 }
4744
4745 async fn execute_host_executor(
4746 &mut self,
4747 _ctx: WorkflowDispatchRun<'_>,
4748 _node: &HostExecutorNode,
4749 _resolved_input: Value,
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
4760 #[tokio::test]
4761 async fn body_failure_causes_loop_failed() {
4762 let run_dir = temp_run_dir("crl-body-fail");
4765 let _ = fs::remove_dir_all(&run_dir);
4766 fs::create_dir_all(run_dir.join("blobs")).unwrap();
4767 let paths = crate::BeamPaths::from_root(run_dir.clone());
4768 let run_id = "run-crl-body-fail";
4769 let def = code_review_loop_def();
4770 let workflow_json = serde_json::to_string(&def).unwrap();
4771 crate::bootstrap_workflow_run(
4772 &paths,
4773 crate::BootstrapWorkflowRunInput {
4774 run_id,
4775 workflow_json: &workflow_json,
4776 expected_workflow_id: Some("code-review-loop"),
4777 params: &BTreeMap::new(),
4778 initiator: "cli",
4779 chat_binding: Some(RunChatBinding {
4780 chat_id: "chat-1".to_string(),
4781 lark_app_id: "app-1".to_string(),
4782 }),
4783 },
4784 )
4785 .unwrap();
4786
4787 let mut rt = WorkflowRuntimeContext {
4788 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4789 def,
4790 runs_base_dir: paths.workflow_runs_dir(),
4791 };
4792 let mut hooks = FailingBodyHooks;
4793 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4794
4795 let events = rt.log.read_all().unwrap();
4796
4797 let loop_started = events.iter().any(|e| e.event_type == "loopStarted");
4799 assert!(loop_started, "expected loopStarted");
4800
4801 let iter_started = events.iter().any(|e| {
4802 e.event_type == "loopIterationStarted"
4803 && e.payload.get("iteration").and_then(Value::as_u64) == Some(1)
4804 });
4805 assert!(iter_started, "expected loopIterationStarted(1)");
4806
4807 let implement_work_id =
4809 format!("{}::loop::review-loop.1::work::implement", run_id);
4810 let has_implement_fail = events.iter().any(|e| {
4811 e.event_type == "activityFailed"
4812 && e.payload.get("activityId").and_then(Value::as_str)
4813 == Some(&implement_work_id)
4814 });
4815 assert!(
4816 has_implement_fail,
4817 "expected implement work to fail: {}",
4818 implement_work_id
4819 );
4820
4821 let loop_failed = events.iter().any(|e| {
4823 e.event_type == "loopFinished"
4824 && e.payload.get("loopId").and_then(Value::as_str) == Some("review-loop")
4825 && e.payload.get("resolution").and_then(Value::as_str) == Some("failed")
4826 });
4827 assert!(
4828 loop_failed,
4829 "expected loopFinished with resolution=failed after body failure"
4830 );
4831
4832 assert!(
4834 matches!(result.reason, RunLoopStopReason::Terminal),
4835 "expected terminal; reason={:?}",
4836 result.reason
4837 );
4838 let run_failed = events.iter().any(|e| e.event_type == "runFailed");
4839 assert!(run_failed, "expected run to fail after loop body failure");
4840
4841 let _ = fs::remove_dir_all(&run_dir);
4842 }
4843
4844 #[tokio::test]
4845 async fn max_iterations_reject_causes_loop_failed() {
4846 let run_dir = temp_run_dir("crl-maxiter");
4849 let _ = fs::remove_dir_all(&run_dir);
4850 fs::create_dir_all(run_dir.join("blobs")).unwrap();
4851 let paths = crate::BeamPaths::from_root(run_dir.clone());
4852 let run_id = "run-crl-maxiter";
4853 let def = code_review_loop_def();
4854 let workflow_json = serde_json::to_string(&def).unwrap();
4855 crate::bootstrap_workflow_run(
4856 &paths,
4857 crate::BootstrapWorkflowRunInput {
4858 run_id,
4859 workflow_json: &workflow_json,
4860 expected_workflow_id: Some("code-review-loop"),
4861 params: &BTreeMap::new(),
4862 initiator: "cli",
4863 chat_binding: Some(RunChatBinding {
4864 chat_id: "chat-1".to_string(),
4865 lark_app_id: "app-1".to_string(),
4866 }),
4867 },
4868 )
4869 .unwrap();
4870
4871 let iter1_gate_id =
4876 format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
4877
4878 {
4880 let mut rt = WorkflowRuntimeContext {
4881 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4882 def: def.clone(),
4883 runs_base_dir: paths.workflow_runs_dir(),
4884 };
4885 let mut hooks = FakeHooks;
4886 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4887 assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4888 }
4889 {
4891 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4892 let _ = log
4893 .append(EventDraft {
4894 event_type: "waitResolved".to_string(),
4895 actor: WorkflowActor::Human,
4896 payload: serde_json::json!({
4897 "activityId": &iter1_gate_id,
4898 "resolution": "rejected",
4899 "by": "reviewer",
4900 "comment": "redo",
4901 }),
4902 timestamp: None,
4903 payload_hash: None,
4904 })
4905 .unwrap();
4906 }
4907 {
4909 let mut rt = WorkflowRuntimeContext {
4910 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4911 def: def.clone(),
4912 runs_base_dir: paths.workflow_runs_dir(),
4913 };
4914 let mut hooks = FakeHooks;
4915 let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4916
4917 let events = rt.log.read_all().unwrap();
4918 let iter2_started = events.iter().any(|e| {
4919 e.event_type == "loopIterationStarted"
4920 && e.payload.get("iteration").and_then(Value::as_u64) == Some(2)
4921 });
4922 assert!(iter2_started, "expected iteration 2 started");
4923 }
4924
4925 {
4927 let mut rt = WorkflowRuntimeContext {
4928 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4929 def: def.clone(),
4930 runs_base_dir: paths.workflow_runs_dir(),
4931 };
4932 let mut hooks = FakeHooks;
4933 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4934 assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4935 }
4936 {
4938 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4939 let iter2_gate_id =
4940 format!("{}::loop::review-loop.2::gate::reviewDecision", run_id);
4941 let _ = log
4942 .append(EventDraft {
4943 event_type: "waitResolved".to_string(),
4944 actor: WorkflowActor::Human,
4945 payload: serde_json::json!({
4946 "activityId": &iter2_gate_id,
4947 "resolution": "rejected",
4948 "by": "reviewer",
4949 "comment": "still no",
4950 }),
4951 timestamp: None,
4952 payload_hash: None,
4953 })
4954 .unwrap();
4955 }
4956 {
4958 let mut rt = WorkflowRuntimeContext {
4959 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4960 def: def.clone(),
4961 runs_base_dir: paths.workflow_runs_dir(),
4962 };
4963 let mut hooks = FakeHooks;
4964 let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4965
4966 let events = rt.log.read_all().unwrap();
4967 let iter3_started = events.iter().any(|e| {
4968 e.event_type == "loopIterationStarted"
4969 && e.payload.get("iteration").and_then(Value::as_u64) == Some(3)
4970 });
4971 assert!(iter3_started, "expected iteration 3 started");
4972 }
4973
4974 {
4976 let mut rt = WorkflowRuntimeContext {
4977 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
4978 def: def.clone(),
4979 runs_base_dir: paths.workflow_runs_dir(),
4980 };
4981 let mut hooks = FakeHooks;
4982 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
4983 assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
4984 }
4985 {
4987 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
4988 let iter3_gate_id =
4989 format!("{}::loop::review-loop.3::gate::reviewDecision", run_id);
4990 let _ = log
4991 .append(EventDraft {
4992 event_type: "waitResolved".to_string(),
4993 actor: WorkflowActor::Human,
4994 payload: serde_json::json!({
4995 "activityId": &iter3_gate_id,
4996 "resolution": "rejected",
4997 "by": "reviewer",
4998 "comment": "still no",
4999 }),
5000 timestamp: None,
5001 payload_hash: None,
5002 })
5003 .unwrap();
5004 }
5005 {
5007 let mut rt = WorkflowRuntimeContext {
5008 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5009 def,
5010 runs_base_dir: paths.workflow_runs_dir(),
5011 };
5012 let mut hooks = FakeHooks;
5013 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5014
5015 let events = rt.log.read_all().unwrap();
5016
5017 let loop_failed = events.iter().any(|e| {
5019 e.event_type == "loopFinished"
5020 && e.payload.get("loopId").and_then(Value::as_str) == Some("review-loop")
5021 && e.payload.get("resolution").and_then(Value::as_str) == Some("failed")
5022 && e.payload.get("errorCode").and_then(Value::as_str)
5023 == Some("MaxIterationsReached")
5024 });
5025 assert!(
5026 loop_failed,
5027 "expected loopFinished with failed/MaxIterationsReached"
5028 );
5029
5030 assert!(
5031 matches!(result.reason, RunLoopStopReason::Terminal),
5032 "expected terminal; reason={:?}",
5033 result.reason
5034 );
5035 let run_failed = events.iter().any(|e| e.event_type == "runFailed");
5036 assert!(
5037 run_failed,
5038 "expected run to fail when max iterations reached"
5039 );
5040 }
5041
5042 let _ = fs::remove_dir_all(&run_dir);
5043 }
5044
5045 #[derive(Clone)]
5051 struct RichFakeHooks;
5052
5053 #[async_trait]
5054 impl WorkflowExecutionHooks for RichFakeHooks {
5055 async fn execute_subagent(
5056 &mut self,
5057 _ctx: WorkflowDispatchRun<'_>,
5058 _node: &SubagentNode,
5059 _resolved_prompt: String,
5060 ) -> Result<WorkflowDispatchOutcome> {
5061 Ok(WorkflowDispatchOutcome::Succeeded {
5062 output: serde_json::json!({"code": "mock-code", "summary": "mock-summary", "preview": "looks good"}),
5063 session: None,
5064 })
5065 }
5066
5067 async fn execute_host_executor(
5068 &mut self,
5069 _ctx: WorkflowDispatchRun<'_>,
5070 _node: &HostExecutorNode,
5071 resolved_input: Value,
5072 ) -> Result<WorkflowDispatchOutcome> {
5073 Ok(WorkflowDispatchOutcome::Succeeded {
5074 output: resolved_input,
5075 session: None,
5076 })
5077 }
5078 }
5079
5080 #[derive(Clone)]
5082 struct RecordingHooks {
5083 prompts: Arc<Mutex<Vec<(String, String)>>>, }
5085
5086 #[async_trait]
5087 impl WorkflowExecutionHooks for RecordingHooks {
5088 async fn execute_subagent(
5089 &mut self,
5090 _ctx: WorkflowDispatchRun<'_>,
5091 _node: &SubagentNode,
5092 resolved_prompt: String,
5093 ) -> Result<WorkflowDispatchOutcome> {
5094 self.prompts.lock().await.push((
5095 _ctx.node_id.to_string(),
5096 resolved_prompt.clone(),
5097 ));
5098 let out = serde_json::json!({"code": "mock-code", "summary": "mock-summary", "preview": "looks good"});
5102 Ok(WorkflowDispatchOutcome::Succeeded {
5103 output: out,
5104 session: None,
5105 })
5106 }
5107
5108 async fn execute_host_executor(
5109 &mut self,
5110 _ctx: WorkflowDispatchRun<'_>,
5111 _node: &HostExecutorNode,
5112 resolved_input: Value,
5113 ) -> Result<WorkflowDispatchOutcome> {
5114 Ok(WorkflowDispatchOutcome::Succeeded {
5115 output: resolved_input,
5116 session: None,
5117 })
5118 }
5119 }
5120
5121 #[tokio::test]
5122 async fn real_code_review_loop_iter1_reaches_awaiting_wait() {
5123 let raw = include_str!("../../../workflows/code-review-loop.workflow.json");
5127 let def = crate::parse_workflow_definition(raw).expect("parse real code-review-loop");
5128 let workflow_json = serde_json::to_string(&def).unwrap();
5129
5130 let run_dir = temp_run_dir("real-crl-iter1");
5131 let _ = fs::remove_dir_all(&run_dir);
5132 fs::create_dir_all(run_dir.join("blobs")).unwrap();
5133 let paths = crate::BeamPaths::from_root(run_dir.clone());
5134 let run_id = "run-real-crl-1";
5135 let params = BTreeMap::from([(String::from("task"), String::from("add CLI echo command"))]);
5136 crate::bootstrap_workflow_run(
5137 &paths,
5138 crate::BootstrapWorkflowRunInput {
5139 run_id,
5140 workflow_json: &workflow_json,
5141 expected_workflow_id: Some("code-review-loop"),
5142 params: ¶ms,
5143 initiator: "cli",
5144 chat_binding: Some(RunChatBinding {
5145 chat_id: "chat-1".to_string(),
5146 lark_app_id: "app-1".to_string(),
5147 }),
5148 },
5149 )
5150 .unwrap();
5151
5152 let prompts = Arc::new(Mutex::new(Vec::new()));
5153 let mut hooks = RecordingHooks {
5154 prompts: prompts.clone(),
5155 };
5156
5157 let mut rt = WorkflowRuntimeContext {
5158 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5159 def,
5160 runs_base_dir: paths.workflow_runs_dir(),
5161 };
5162 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5163
5164 assert_eq!(
5165 result.reason,
5166 RunLoopStopReason::AwaitingWait,
5167 "expected AwaitingWait for open human gate"
5168 );
5169
5170 let recorded = prompts.lock().await;
5173 let implement_prompt = recorded
5174 .iter()
5175 .find(|(node_id, _)| node_id == "implement")
5176 .map(|(_, p)| p.clone())
5177 .expect("implement must have been dispatched");
5178 eprintln!("implement prompt iter1: {implement_prompt}");
5179 assert!(
5180 implement_prompt.contains("add CLI echo command"),
5181 "implement prompt should contain the task param"
5182 );
5183 assert!(
5185 !implement_prompt.contains("ERROR")
5186 && !implement_prompt.contains("no previous iteration"),
5187 "implement prompt should not contain binding errors: {implement_prompt}"
5188 );
5189
5190 let events = rt.log.read_all().unwrap();
5192 let decision_gate_id =
5193 format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
5194 let has_gate_wait = events.iter().any(|e| {
5195 e.event_type == "waitCreated"
5196 && e.payload.get("activityId").and_then(Value::as_str)
5197 == Some(&decision_gate_id)
5198 });
5199 assert!(
5200 has_gate_wait,
5201 "expected gate wait for {}",
5202 decision_gate_id
5203 );
5204
5205 let _ = fs::remove_dir_all(&run_dir);
5206 }
5207
5208 #[tokio::test]
5209 async fn real_code_review_loop_reject_enters_iter2_with_comment() {
5210 let raw = include_str!("../../../workflows/code-review-loop.workflow.json");
5214 let def = crate::parse_workflow_definition(raw).expect("parse real code-review-loop");
5215 let workflow_json = serde_json::to_string(&def).unwrap();
5216
5217 let run_dir = temp_run_dir("real-crl-reject");
5218 let _ = fs::remove_dir_all(&run_dir);
5219 fs::create_dir_all(run_dir.join("blobs")).unwrap();
5220 let paths = crate::BeamPaths::from_root(run_dir.clone());
5221 let run_id = "run-real-crl-rej";
5222 let params = BTreeMap::from([(String::from("task"), String::from("add test"))]);
5223 crate::bootstrap_workflow_run(
5224 &paths,
5225 crate::BootstrapWorkflowRunInput {
5226 run_id,
5227 workflow_json: &workflow_json,
5228 expected_workflow_id: Some("code-review-loop"),
5229 params: ¶ms,
5230 initiator: "cli",
5231 chat_binding: Some(RunChatBinding {
5232 chat_id: "chat-1".to_string(),
5233 lark_app_id: "app-1".to_string(),
5234 }),
5235 },
5236 )
5237 .unwrap();
5238
5239 let prompts = Arc::new(Mutex::new(Vec::new()));
5240
5241 {
5243 let mut hooks = RecordingHooks {
5244 prompts: prompts.clone(),
5245 };
5246 let mut rt = WorkflowRuntimeContext {
5247 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5248 def: def.clone(),
5249 runs_base_dir: paths.workflow_runs_dir(),
5250 };
5251 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5252 assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
5253 }
5254
5255 let decision_gate_id =
5257 format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
5258 {
5259 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
5260 let _ = log
5261 .append(EventDraft {
5262 event_type: "waitResolved".to_string(),
5263 actor: WorkflowActor::Human,
5264 payload: serde_json::json!({
5265 "activityId": &decision_gate_id,
5266 "resolution": "rejected",
5267 "by": "reviewer",
5268 "comment": "needs more tests",
5269 }),
5270 timestamp: None,
5271 payload_hash: None,
5272 })
5273 .unwrap();
5274 }
5275
5276 {
5278 let mut hooks = RecordingHooks {
5279 prompts: prompts.clone(),
5280 };
5281 let mut rt = WorkflowRuntimeContext {
5282 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5283 def: def.clone(),
5284 runs_base_dir: paths.workflow_runs_dir(),
5285 };
5286 let _result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5287
5288 let events = rt.log.read_all().unwrap();
5290 let iter1_finished = events.iter().find(|e| {
5291 e.event_type == "loopIterationFinished"
5292 && e.payload.get("iteration").and_then(Value::as_u64) == Some(1)
5293 });
5294 assert!(iter1_finished.is_some(), "expected loopIterationFinished for iteration 1");
5295 let payload = &iter1_finished.unwrap().payload;
5296 assert_eq!(
5297 payload.get("resolution").and_then(Value::as_str),
5298 Some("rejected"),
5299 "resolution should be rejected"
5300 );
5301 assert_eq!(
5302 payload.get("by").and_then(Value::as_str),
5303 Some("reviewer"),
5304 "by should be reviewer"
5305 );
5306 assert_eq!(
5307 payload.get("comment").and_then(Value::as_str),
5308 Some("needs more tests"),
5309 "comment should be preserved"
5310 );
5311 assert_eq!(
5312 payload.get("decisionActivityId").and_then(Value::as_str),
5313 Some(decision_gate_id.as_str()),
5314 "decisionActivityId should be the gate id"
5315 );
5316
5317 let iter2_started = events.iter().any(|e| {
5318 e.event_type == "loopIterationStarted"
5319 && e.payload.get("iteration").and_then(Value::as_u64) == Some(2)
5320 });
5321 assert!(iter2_started, "expected iteration 2 started");
5322 }
5323
5324 let recorded = prompts.lock().await;
5328 let iter2_implement = recorded
5329 .iter()
5330 .filter(|(node_id, _)| node_id == "implement")
5331 .last()
5332 .map(|(_, p)| p.clone())
5333 .expect("implement iter2 must have been dispatched");
5334 eprintln!("implement prompt iter2: {iter2_implement}");
5335 assert!(
5336 iter2_implement.contains("needs more tests"),
5337 "iter2 implement prompt should contain reject comment 'needs more tests': {iter2_implement}"
5338 );
5339
5340 let _ = fs::remove_dir_all(&run_dir);
5341 }
5342
5343 #[tokio::test]
5344 async fn real_code_review_loop_approve_succeeds() {
5345 let raw = include_str!("../../../workflows/code-review-loop.workflow.json");
5347 let def = crate::parse_workflow_definition(raw).expect("parse real code-review-loop");
5348 let workflow_json = serde_json::to_string(&def).unwrap();
5349
5350 let run_dir = temp_run_dir("real-crl-approve");
5351 let _ = fs::remove_dir_all(&run_dir);
5352 fs::create_dir_all(run_dir.join("blobs")).unwrap();
5353 let paths = crate::BeamPaths::from_root(run_dir.clone());
5354 let run_id = "run-real-crl-app";
5355 let params = BTreeMap::from([(String::from("task"), String::from("add feature"))]);
5356 crate::bootstrap_workflow_run(
5357 &paths,
5358 crate::BootstrapWorkflowRunInput {
5359 run_id,
5360 workflow_json: &workflow_json,
5361 expected_workflow_id: Some("code-review-loop"),
5362 params: ¶ms,
5363 initiator: "cli",
5364 chat_binding: Some(RunChatBinding {
5365 chat_id: "chat-1".to_string(),
5366 lark_app_id: "app-1".to_string(),
5367 }),
5368 },
5369 )
5370 .unwrap();
5371
5372 {
5374 let mut hooks = RichFakeHooks;
5375 let mut rt = WorkflowRuntimeContext {
5376 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5377 def: def.clone(),
5378 runs_base_dir: paths.workflow_runs_dir(),
5379 };
5380 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5381 assert_eq!(result.reason, RunLoopStopReason::AwaitingWait);
5382 }
5383
5384 let decision_gate_id =
5386 format!("{}::loop::review-loop.1::gate::reviewDecision", run_id);
5387 {
5388 let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
5389 let _ = log
5390 .append(EventDraft {
5391 event_type: "waitResolved".to_string(),
5392 actor: WorkflowActor::Human,
5393 payload: serde_json::json!({
5394 "activityId": &decision_gate_id,
5395 "resolution": "approved",
5396 "by": "approver",
5397 "comment": "lgtm",
5398 }),
5399 timestamp: None,
5400 payload_hash: None,
5401 })
5402 .unwrap();
5403 }
5404
5405 {
5407 let mut hooks = RichFakeHooks;
5408 let mut rt = WorkflowRuntimeContext {
5409 log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
5410 def,
5411 runs_base_dir: paths.workflow_runs_dir(),
5412 };
5413 let result = run_loop(&mut rt, &mut hooks, 10, 1).await.unwrap();
5414
5415 let events = rt.log.read_all().unwrap();
5416
5417 let loop_finished = events.iter().find(|e| {
5418 e.event_type == "loopFinished"
5419 && e.payload.get("loopId").and_then(Value::as_str) == Some("review-loop")
5420 });
5421 assert!(loop_finished.is_some(), "expected loopFinished");
5422 let payload = &loop_finished.unwrap().payload;
5423 assert_eq!(
5424 payload.get("resolution").and_then(Value::as_str),
5425 Some("approved")
5426 );
5427
5428 let run_succeeded = events.iter().any(|e| e.event_type == "runSucceeded");
5429 assert!(run_succeeded, "expected run to succeed after loop approval");
5430
5431 assert!(matches!(result.reason, RunLoopStopReason::Terminal));
5432
5433 let snap = read_snapshot(&rt).await.unwrap();
5435 let loop_output_key = format!("{}::work::review-loop", run_id);
5436 assert!(
5437 snap.outputs.contains_key(&loop_output_key),
5438 "expected loop output"
5439 );
5440 }
5441
5442 let _ = fs::remove_dir_all(&run_dir);
5443 }
5444}