use std::collections::BTreeMap;
use std::fs;
use std::sync::Arc;
use async_trait::async_trait;
use beam_core::{
BeamPaths, EventLog, RequestCancelInput, RunChatBinding, RunLoopStopReason, RunStatus,
WorkflowActor, WorkflowDispatchOutcome, WorkflowDispatchRun, WorkflowExecutionHooks,
WorkflowRuntimeContext, bootstrap_workflow_run, read_run_snapshot, request_cancel, run_loop,
run_tick,
workflow_definition::{
HostExecutorNode, NodeBase, SubagentNode, WorkflowDefinition, WorkflowNode,
},
};
use serde_json::Value;
use tokio::sync::Mutex;
fn temp_run_dir(label: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"beam-workflow-resume-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
))
}
fn workflow_def() -> WorkflowDefinition {
WorkflowDefinition {
workflow_id: "flow-resume".to_string(),
version: 1,
params: None,
defaults: None,
nodes: BTreeMap::from([
(
"a".to_string(),
WorkflowNode::Subagent(SubagentNode {
base: NodeBase {
description: None,
depends: None,
human_gate: None,
retry_policy: None,
timeout_ms: None,
max_output_bytes: None,
output_schema: None,
unsafe_allow_ungated: None,
},
bot: "bot-a".to_string(),
prompt: Value::String("task-a".to_string()),
working_dir: None,
model_overrides: None,
tool_policy: None,
}),
),
(
"b".to_string(),
WorkflowNode::Subagent(SubagentNode {
base: NodeBase {
description: None,
depends: Some(vec!["a".to_string()]),
human_gate: None,
retry_policy: None,
timeout_ms: None,
max_output_bytes: None,
output_schema: None,
unsafe_allow_ungated: None,
},
bot: "bot-b".to_string(),
prompt: Value::String("task-b".to_string()),
working_dir: None,
model_overrides: None,
tool_policy: None,
}),
),
]),
}
}
#[derive(Clone)]
struct CountingHooks {
call_count: Arc<Mutex<usize>>,
node_fail: Arc<Mutex<Option<String>>>,
}
impl CountingHooks {
fn new() -> Self {
Self {
call_count: Arc::new(Mutex::new(0)),
node_fail: Arc::new(Mutex::new(None)),
}
}
#[allow(dead_code)]
fn with_fail(node_id: &str) -> Self {
Self {
call_count: Arc::new(Mutex::new(0)),
node_fail: Arc::new(Mutex::new(Some(node_id.to_string()))),
}
}
}
#[async_trait]
impl WorkflowExecutionHooks for CountingHooks {
async fn execute_subagent(
&mut self,
_ctx: WorkflowDispatchRun<'_>,
node: &SubagentNode,
resolved_prompt: String,
) -> anyhow::Result<WorkflowDispatchOutcome> {
let mut count = self.call_count.lock().await;
*count += 1;
let fail_node = self.node_fail.lock().await.clone();
if fail_node.as_deref() == Some(node.bot.as_str()) || fail_node.as_deref() == Some("*") {
return Ok(WorkflowDispatchOutcome::Failed {
error_code: "E_TEST".to_string(),
error_class: "TEST_FAILURE".to_string(),
error_message: "simulated failure".to_string(),
session: None,
});
}
Ok(WorkflowDispatchOutcome::Succeeded {
output: Value::String(format!("result-{}", resolved_prompt)),
session: None,
})
}
async fn execute_host_executor(
&mut self,
_ctx: WorkflowDispatchRun<'_>,
_node: &HostExecutorNode,
resolved_input: Value,
) -> anyhow::Result<WorkflowDispatchOutcome> {
Ok(WorkflowDispatchOutcome::Succeeded {
output: resolved_input,
session: None,
})
}
}
#[tokio::test]
async fn resume_from_prior_tick_continues_with_remaining_nodes() {
let run_dir = temp_run_dir("resume-continue");
let _ = fs::remove_dir_all(&run_dir);
fs::create_dir_all(run_dir.join("blobs")).unwrap();
let paths = BeamPaths::from_root(run_dir.clone());
let params = BTreeMap::new();
let run_id = "run-resume-1";
bootstrap_workflow_run(
&paths,
beam_core::BootstrapWorkflowRunInput {
run_id,
workflow_json: r#"{"workflowId":"flow-resume","version":1,"nodes":{"a":{"type":"subagent","bot":"bot-a","prompt":"task-a"},"b":{"type":"subagent","bot":"bot-b","prompt":"task-b","depends":["a"]}}}"#,
expected_workflow_id: Some("flow-resume"),
params: ¶ms,
initiator: "cli",
chat_binding: Some(RunChatBinding {
chat_id: "chat-1".to_string(),
lark_app_id: "app-1".to_string(),
}),
},
)
.unwrap();
let mut rt = WorkflowRuntimeContext {
log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
def: workflow_def(),
runs_base_dir: paths.workflow_runs_dir(),
};
let mut hooks = CountingHooks::new();
let tick = run_tick(&mut rt, &mut hooks, 1).await.unwrap();
assert_eq!(tick.actions, 1);
assert_eq!(*hooks.call_count.lock().await, 1);
let snapshot1 = read_run_snapshot(&paths.workflow_run_dir(run_id))
.await
.unwrap()
.unwrap();
assert_eq!(snapshot1.run.status, RunStatus::Running);
let mut rt2 = WorkflowRuntimeContext {
log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
def: workflow_def(),
runs_base_dir: paths.workflow_runs_dir(),
};
let mut hooks2 = CountingHooks::new();
let result = run_loop(&mut rt2, &mut hooks2, 5, 1).await.unwrap();
assert!(matches!(
result.reason,
RunLoopStopReason::Terminal | RunLoopStopReason::NoProgress
));
assert_eq!(*hooks2.call_count.lock().await, 1);
assert!(result.ticks > 0);
let final_snapshot = read_run_snapshot(&paths.workflow_run_dir(run_id))
.await
.unwrap()
.unwrap();
assert!(matches!(final_snapshot.run.status, RunStatus::Succeeded));
let _ = fs::remove_dir_all(&run_dir);
}
#[tokio::test]
async fn cancel_intent_persists_and_is_readable_after_reload() {
let run_dir = temp_run_dir("cancel-persist");
let _ = fs::remove_dir_all(&run_dir);
fs::create_dir_all(run_dir.join("blobs")).unwrap();
let paths = BeamPaths::from_root(run_dir.clone());
let params = BTreeMap::new();
let run_id = "run-cancel-persist";
bootstrap_workflow_run(
&paths,
beam_core::BootstrapWorkflowRunInput {
run_id,
workflow_json: r#"{"workflowId":"flow-resume","version":1,"nodes":{"a":{"type":"subagent","bot":"bot-a","prompt":"task-a"}}}"#,
expected_workflow_id: Some("flow-resume"),
params: ¶ms,
initiator: "cli",
chat_binding: Some(RunChatBinding {
chat_id: "chat-1".to_string(),
lark_app_id: "app-1".to_string(),
}),
},
)
.unwrap();
{
let mut log = EventLog::new(run_id, paths.workflow_runs_dir()).unwrap();
request_cancel(
&mut log,
RequestCancelInput {
target: serde_json::json!({
"kind": "run",
"runId": run_id,
}),
reason: "test cancel".to_string(),
by: "tester".to_string(),
},
WorkflowActor::Human,
)
.await
.unwrap();
}
let mut rt = WorkflowRuntimeContext {
log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
def: WorkflowDefinition {
workflow_id: "flow-resume".to_string(),
version: 1,
params: None,
defaults: None,
nodes: BTreeMap::from([(
"a".to_string(),
WorkflowNode::Subagent(SubagentNode {
base: NodeBase {
description: None,
depends: None,
human_gate: None,
retry_policy: None,
timeout_ms: None,
max_output_bytes: None,
output_schema: None,
unsafe_allow_ungated: None,
},
bot: "bot-a".to_string(),
prompt: Value::String("task-a".to_string()),
working_dir: None,
model_overrides: None,
tool_policy: None,
}),
)]),
},
runs_base_dir: paths.workflow_runs_dir(),
};
let mut hooks = CountingHooks::new();
let tick = run_tick(&mut rt, &mut hooks, 2).await.unwrap();
assert_eq!(tick.actions, 0);
assert!(tick.snapshot.run.cancelled_run_intent.is_some());
assert_eq!(
tick.snapshot
.run
.cancelled_run_intent
.as_ref()
.unwrap()
.reason,
"test cancel"
);
let mut rt2 = WorkflowRuntimeContext {
log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
def: WorkflowDefinition {
workflow_id: "flow-resume".to_string(),
version: 1,
params: None,
defaults: None,
nodes: BTreeMap::from([(
"a".to_string(),
WorkflowNode::Subagent(SubagentNode {
base: NodeBase {
description: None,
depends: None,
human_gate: None,
retry_policy: None,
timeout_ms: None,
max_output_bytes: None,
output_schema: None,
unsafe_allow_ungated: None,
},
bot: "bot-a".to_string(),
prompt: Value::String("task-a".to_string()),
working_dir: None,
model_overrides: None,
tool_policy: None,
}),
)]),
},
runs_base_dir: paths.workflow_runs_dir(),
};
let mut hooks2 = CountingHooks::new();
let tick2 = run_tick(&mut rt2, &mut hooks2, 2).await.unwrap();
assert_eq!(tick2.actions, 0);
assert!(tick2.snapshot.run.cancelled_run_intent.is_some());
let _ = fs::remove_dir_all(&run_dir);
}
#[tokio::test]
async fn resume_after_partial_concurrency_completes_remaining() {
let run_dir = temp_run_dir("concurrent-resume");
let _ = fs::remove_dir_all(&run_dir);
fs::create_dir_all(run_dir.join("blobs")).unwrap();
let paths = BeamPaths::from_root(run_dir.clone());
let params = BTreeMap::new();
let run_id = "run-concurrent-resume";
bootstrap_workflow_run(
&paths,
beam_core::BootstrapWorkflowRunInput {
run_id,
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"},"c":{"type":"subagent","bot":"bot-c","prompt":"three"}}}"#,
expected_workflow_id: Some("flow-concurrent"),
params: ¶ms,
initiator: "cli",
chat_binding: Some(RunChatBinding {
chat_id: "chat-1".to_string(),
lark_app_id: "app-1".to_string(),
}),
},
)
.unwrap();
let three_node_def = WorkflowDefinition {
workflow_id: "flow-concurrent".to_string(),
version: 1,
params: None,
defaults: None,
nodes: BTreeMap::from([
(
"a".to_string(),
WorkflowNode::Subagent(SubagentNode {
base: NodeBase {
description: None,
depends: None,
human_gate: None,
retry_policy: None,
timeout_ms: None,
max_output_bytes: None,
output_schema: None,
unsafe_allow_ungated: None,
},
bot: "bot-a".to_string(),
prompt: Value::String("one".to_string()),
working_dir: None,
model_overrides: None,
tool_policy: None,
}),
),
(
"b".to_string(),
WorkflowNode::Subagent(SubagentNode {
base: NodeBase {
description: None,
depends: None,
human_gate: None,
retry_policy: None,
timeout_ms: None,
max_output_bytes: None,
output_schema: None,
unsafe_allow_ungated: None,
},
bot: "bot-b".to_string(),
prompt: Value::String("two".to_string()),
working_dir: None,
model_overrides: None,
tool_policy: None,
}),
),
(
"c".to_string(),
WorkflowNode::Subagent(SubagentNode {
base: NodeBase {
description: None,
depends: None,
human_gate: None,
retry_policy: None,
timeout_ms: None,
max_output_bytes: None,
output_schema: None,
unsafe_allow_ungated: None,
},
bot: "bot-c".to_string(),
prompt: Value::String("three".to_string()),
working_dir: None,
model_overrides: None,
tool_policy: None,
}),
),
]),
};
let mut rt = WorkflowRuntimeContext {
log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
def: three_node_def.clone(),
runs_base_dir: paths.workflow_runs_dir(),
};
let mut hooks = CountingHooks::new();
let tick = run_tick(&mut rt, &mut hooks, 2).await.unwrap();
assert_eq!(tick.actions, 2);
let mut rt2 = WorkflowRuntimeContext {
log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
def: three_node_def.clone(),
runs_base_dir: paths.workflow_runs_dir(),
};
let mut hooks2 = CountingHooks::new();
let result = run_loop(&mut rt2, &mut hooks2, 5, 3).await.unwrap();
assert!(matches!(
result.reason,
RunLoopStopReason::Terminal | RunLoopStopReason::NoProgress
));
assert_eq!(*hooks2.call_count.lock().await, 1);
let final_snapshot = read_run_snapshot(&paths.workflow_run_dir(run_id))
.await
.unwrap()
.unwrap();
let succeeded: Vec<_> = final_snapshot
.nodes
.iter()
.filter(|n| n.status == beam_core::NodeStatus::Succeeded)
.map(|n| n.node_id.clone())
.collect();
assert_eq!(succeeded.len(), 3);
assert!(succeeded.contains(&"a".to_string()));
assert!(succeeded.contains(&"b".to_string()));
assert!(succeeded.contains(&"c".to_string()));
let _ = fs::remove_dir_all(&run_dir);
}
#[tokio::test]
async fn reload_respects_existing_node_status_on_resume() {
let run_dir = temp_run_dir("status-resume");
let _ = fs::remove_dir_all(&run_dir);
fs::create_dir_all(run_dir.join("blobs")).unwrap();
let paths = BeamPaths::from_root(run_dir.clone());
let params = BTreeMap::new();
let run_id = "run-status-resume";
bootstrap_workflow_run(
&paths,
beam_core::BootstrapWorkflowRunInput {
run_id,
workflow_json: r#"{"workflowId":"flow-resume","version":1,"nodes":{"a":{"type":"subagent","bot":"bot-a","prompt":"task-a"},"b":{"type":"subagent","bot":"bot-b","prompt":"task-b","depends":["a"]}}}"#,
expected_workflow_id: Some("flow-resume"),
params: ¶ms,
initiator: "cli",
chat_binding: Some(RunChatBinding {
chat_id: "chat-1".to_string(),
lark_app_id: "app-1".to_string(),
}),
},
)
.unwrap();
let mut rt = WorkflowRuntimeContext {
log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
def: workflow_def(),
runs_base_dir: paths.workflow_runs_dir(),
};
let mut hooks = CountingHooks::new();
let tick = run_tick(&mut rt, &mut hooks, 1).await.unwrap();
assert_eq!(tick.actions, 1);
let snapshot1 = read_run_snapshot(&paths.workflow_run_dir(run_id))
.await
.unwrap()
.unwrap();
let a_status = snapshot1
.nodes
.iter()
.find(|n| n.node_id == "a")
.map(|n| n.status)
.unwrap();
assert_eq!(a_status, beam_core::NodeStatus::Running);
let mut rt2 = WorkflowRuntimeContext {
log: EventLog::new(run_id, paths.workflow_runs_dir()).unwrap(),
def: workflow_def(),
runs_base_dir: paths.workflow_runs_dir(),
};
let mut hooks2 = CountingHooks::new();
let result = run_loop(&mut rt2, &mut hooks2, 5, 1).await.unwrap();
assert!(matches!(
result.reason,
RunLoopStopReason::Terminal | RunLoopStopReason::NoProgress
));
assert_eq!(*hooks2.call_count.lock().await, 1);
let snapshot_check = read_run_snapshot(&paths.workflow_run_dir(run_id))
.await
.unwrap()
.unwrap();
let a_check = snapshot_check
.nodes
.iter()
.find(|n| n.node_id == "a")
.map(|n| n.status)
.unwrap();
assert_eq!(a_check, beam_core::NodeStatus::Succeeded);
let _ = fs::remove_dir_all(&run_dir);
}