Files
DevFlow/src-tauri/src/commands/workflow.rs
T
lxy f736f435bc 优化: 所有剩余UI/UX待办一批完成(持久化+AuditLog+解耦+total+原12大改+P2)
持久化(P1-c):新建 usePersistedRef composable,Tasks/AuditLog/ProjectDetail 等接入 localStorage

AuditLog(P1-d):后端 list_tool_executions 加 WHERE 筛选+返 {items,total,has_more},前端对接+长列折叠+筛选持久化

数据源解耦(P1-g):ProjectDetail projectTasks 按 project_id 独立加载 + ChatInput @项目联想独立加载(不读 store.tasks 当前页)

GitChanges(12a):后端 get_module_commits 加 git rev-list --count 返 total,前端显真实总数

原12大改:Dashboard 统计卡压底行(1)/Projects 列表卡片视图(2)/project_event 埋点排序(3)/TaskDetail 重设计(4)/IdeaDetail 重设计(5)/KnowledgeDetail 重设计(6)/界面持久化+侧栏Ctrl+B+审批数字键(7)/ProjectDetail 三栏改两栏(10)

P2打磨:文件浏览器(FileTree去重/FilePreview行号.md Diff/selectedFilePath归位)/settings反馈(假保存/端口校验)/AI会话(try-catch/scrollIntoView)/后端计数(move_queue事件/timeline total/workflow分页/import_batch分块)/杂项(TopBar/ConfirmDialog键盘/CIStatus i18n/ToolResultBody/ModuleNode/ApprovalDialog全选)
2026-08-02 13:11:06 +08:00

580 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 工作流相关命令 — 触发执行、查询执行记录
//!
//! 事件转发:run_workflow 在执行前订阅事件总线,
//! 将 WorkflowEvent 包装为 `{ execution_id, event }` 通过 `workflow-event` 事件发给前端。
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
use tokio::sync::broadcast::error::RecvError;
use df_types::events::{WorkflowEvent, HumanApprovalResponse, SelectType};
use df_types::types::{new_id, ExecutionId, NodeStatus};
use df_nodes::task_advance_node::advance_task_atomic;
// F-260616-06 ②-4: 工作流失败时按 target_status 推算任务退回态。
// regression_target 已收敛到 df-nodes::task_state_machine(状态机业务契约,单一可信源),
// 不再在 app crate 维护私有副本(防 DRY 漂移)。
use df_nodes::task_state_machine::regression_target;
// F-260616-06 ①-1: 空 dag + target_status 时按目标态自动选推进链模板
use df_nodes::task_workflow_templates::template_for;
use df_storage::crud::{TaskRepo, WorkflowRepo};
use df_storage::models::WorkflowRecord;
use df_workflow::dag_def::DagDef;
use df_workflow::executor::DagExecutor;
use crate::state::AppState;
use super::{err_str, now_millis};
/// 转发到前端的事件载荷
#[derive(Debug, Clone, Serialize)]
struct WorkflowEventPayload {
/// 工作流执行记录 ID
execution_id: ExecutionId,
/// 原始工作流事件(serde tag = "type"
event: WorkflowEvent,
}
/// Lagged 兜底:查 DB 终态,命中返回 `(要补发的事件, status)`;未命中返回 None。
///
/// broadcast `Lagged` 不暴露被丢事件类型,关键终态事件(WorkflowCompleted/Failed)可能已丢,
/// forward 循环会永久等不到。达阈值时查 DB 终态补发。三种"未命中"(仍 running / 无记录 /
/// 查询失败)统一返回 None 并各自 warn,主循环据此重置 `lagged_total` 继续等。
async fn probe_lagged_terminal(
db: &df_storage::db::Database,
exec_id: &ExecutionId,
) -> Option<(WorkflowEvent, String)> {
let record = match WorkflowRepo::new(db).get_by_id(exec_id).await {
Ok(Some(r)) => r,
Ok(None) => {
tracing::warn!(
execution_id = %exec_id,
"Lagged 兜底查询未找到执行记录,继续等待事件"
);
return None;
}
Err(e) => {
tracing::warn!(
execution_id = %exec_id,
error = %e,
"Lagged 兜底查询 DB 失败,继续等待事件"
);
return None;
}
};
let event = match record.status.as_str() {
"completed" => WorkflowEvent::WorkflowCompleted {
execution_id: exec_id.clone(),
total_duration_ms: 0,
},
"failed" => WorkflowEvent::WorkflowFailed {
execution_id: exec_id.clone(),
error: "工作流执行失败(Lagged 兜底补发,详情见 DB".to_string(),
failed_node: String::new(),
},
"cancelled" => WorkflowEvent::WorkflowFailed {
execution_id: exec_id.clone(),
error: "工作流被取消(Lagged 兜底补发)".to_string(),
failed_node: String::new(),
},
_ => return None,
};
Some((event, record.status))
}
/// 触发工作流执行(核心命令)
///
/// 流程:build_dag 校验 → 写入执行记录(status=running) → 后台异步执行 →
/// 事件经 EventBus 转发到前端 → 完成后更新执行记录状态。
/// 立即返回执行记录 ID,前端凭此关联后续事件。
///
/// F-260616-06 阶段2 工作流联动(②-2):
/// - `task_id` / `target_status` 同时 Some 时,完成后按 target_status 推进任务(②-3),
/// 失败时按 regression_target 退回(②-4)。
/// - Option 向后兼容:旧调用方不传即 None,不触发任何任务回调,零行为破坏。
#[tauri::command]
pub async fn run_workflow(
app: AppHandle,
state: State<'_, AppState>,
name: String,
dag: DagDef,
config: serde_json::Value,
// F-260616-06 ②-2: 工作流联动任务的关联 ID 与目标态(同时 Some 才联动)
task_id: Option<String>,
target_status: Option<String>,
) -> Result<String, String> {
// 瘦转发到 run_workflow_inner(B-260617-01:抽取共用核心供 ai_approve 经
// execute_run_workflow_for_tool 直接调,无需构造 tauri::State)。
// CR-52: 命令层(前端 invoke run_workflow)是人工手动触发 → triggered_by="manual"。
run_workflow_inner(&app, &state, name, dag, config, task_id, target_status, "manual").await
}
/// 工作流执行核心逻辑(B-260617-01 抽取,命令层瘦转发)。
///
/// 原 `run_workflow` 命令体拆出,使 ai_approve 经 execute_run_workflow_for_tool 直接调用
/// (持 &AppState 而非 tauri::State,绕开 tauri::State 在非命令上下文不可构造的限制)。
/// 语义与原命令完全一致:模板选 DAG → 落执行记录 → spawn 事件转发 + DAG 执行 + 任务联动推进。
///
/// `triggered_by` 落入 WorkflowRecord.triggered_by,区分触发来源:
/// - 命令层 run_workflow(前端 invoke,人工手动)传 "manual"
/// - execute_run_workflow_for_tool(AI 工具经 ai_approve 审批后执行)传 "ai"
/// CR-52:原硬编码 "manual" 致 AI 路径误标,改由调用方透传。
pub async fn run_workflow_inner(
app: &AppHandle,
state: &AppState,
name: String,
dag: DagDef,
config: serde_json::Value,
task_id: Option<String>,
target_status: Option<String>,
triggered_by: &str,
) -> Result<String, String> {
// 0. F-260616-06 ①-1: DagDef 来源选模板(方案A·最小)。
// 规则:
// - dag.nodes 非空 → 用传入 dag(向后兼容,旧调用方/编辑器自定义工作流不变)
// - dag.nodes 为空 + target_status Some → 调 template_for 自动选推进链模板;
// 模板不存在(target 非三合法态) → Err「无对应工作流模板」
// - dag.nodes 为空 + target_status None → 原行为(空 dag 让 build_dag 自行报错,零新分支)
// 前端选 target_status 后传空 dag,零模板知识零新 IPC,后端收敛选模板逻辑。
let dag = if dag.nodes.is_empty() {
match target_status.as_deref() {
Some(target) => template_for(target)
.ok_or_else(|| format!("无对应工作流模板: target_status={}", target))?,
None => dag, // 空dag+无target: 走原路径由 build_dag 校验
}
} else {
dag
};
// 1. 先构建运行时 DAG,校验失败直接返回,不落库
let runtime_dag = state.registry.build_dag(&dag).map_err(err_str)?;
// 2. 写入执行记录(status=running
let execution_id: ExecutionId = new_id().into();
let dag_json = serde_json::to_string(&dag).map_err(err_str)?;
let record = WorkflowRecord {
id: execution_id.to_string(),
name,
dag_json,
status: "running".to_string(),
// CR-52: triggered_by 由调用方透传(命令层 run_workflow="manual",
// execute_run_workflow_for_tool="ai"),不再硬编码 "manual" 致 AI 路径误标。
triggered_by: Some(triggered_by.to_string()),
project_id: None,
task_id: task_id.clone(),
created_at: now_millis(),
completed_at: None,
};
state
.workflows
.insert(record)
.await
.map_err(err_str)?;
// 3. 订阅事件总线,把事件转发给前端(收到完成/失败事件后退出)
let mut rx = state.event_bus.subscribe();
let forward_app = app.clone();
let forward_exec_id = execution_id.clone();
// B-260615-35: forward 任务内需查 DB 终态兜底 Lagged 丢终态事件,故 move 进 db 句柄
let forward_db = state.db.clone();
tauri::async_runtime::spawn(async move {
// Lagged 累计次数(用于"累计达阈值"触发兜底查询)
let mut lagged_total: u64 = 0;
// Lagged 单次/累计触发 DB 终态查询兜底的阈值
const LAGGED_PROBE_THRESHOLD: u64 = 8;
loop {
match rx.recv().await {
Ok(event) => {
// B-03b-R10 ③(波17 治本): 完成判定加 execution_id 匹配。
// AppState.event_bus 是全局单例,并发工作流各自订阅同一总线;
// 此前 matches! 只看变体不看 exec_id,任一工作流的 WorkflowCompleted/Failed
// 都会触发本 forward 循环 break,过早断他人链(并发串扰根因)。
// 现在按变体+exec_id 双重匹配:只处理属于本 forward_exec_id 的终态事件,
// 他人终态事件忽略(emit 给前端后继续循环),不再误 break。
// execution_id 字段 #[serde(default)] 向后兼容老事件(空串):
// 自家终态事件本就带正确 exec_id;空串匹配只可能漏(自家老事件),不会误 break。
let finished = matches!(
&event,
WorkflowEvent::WorkflowCompleted { execution_id, .. }
| WorkflowEvent::WorkflowFailed { execution_id, .. }
// execution_id: WorkflowEvent 中反序列化的 ExecutionId(#[serde(default)]),
// forward_exec_id: 本 forward 循环所属的 ExecutionId,同类型直接比较
if execution_id == &forward_exec_id
);
let payload = WorkflowEventPayload {
execution_id: forward_exec_id.clone(),
event,
};
if let Err(e) = forward_app.emit("workflow-event", &payload) {
tracing::warn!("工作流事件转发失败: {}", e);
}
if finished {
break;
}
}
// R-PD-13: Lagged 表示消费过慢,broadcast 滑动窗口丢 n 条最旧事件。
// broadcast 不暴露被丢事件的具体类型,无法在此精确判断是否为关键终态事件。
// 风险:若关键终态事件(WorkflowCompleted/WorkflowFailed)被丢,finished 判定不触发,
// 循环不会 break,前端永久收不到工作流结束信号。
// B-260615-35 兜底:收到 Lagged 时,累计次数或单次 n 达阈值则查 DB 终态,
// 若工作流已终态则补发对应 workflow-event 并 break 退出 forward。
// 避免依赖实时性差的 DB 轮询兜底导致前端审批/完成弹窗卡死。
Err(RecvError::Lagged(n)) => {
lagged_total = lagged_total.saturating_add(n);
tracing::warn!(
execution_id = %forward_exec_id,
lagged = n,
lagged_total = lagged_total,
"工作流事件消费滞后,丢失 {} 条事件(broadcast 不暴露被丢事件类型,关键终态事件可能已丢失)",
n
);
if lagged_total < LAGGED_PROBE_THRESHOLD {
continue;
}
// 累计/单次达阈值:查 DB 终态兜底(probe_lagged_terminal 收口三种未命中情况)
match probe_lagged_terminal(&forward_db, &forward_exec_id).await {
Some((synth_event, status)) => {
let payload = WorkflowEventPayload {
execution_id: forward_exec_id.clone(),
event: synth_event.clone(),
};
if let Err(e) = forward_app.emit("workflow-event", &payload) {
tracing::warn!("Lagged 终态兜底事件转发失败: {}", e);
}
tracing::info!(
execution_id = %forward_exec_id,
status = %status,
"Lagged 兜底命中终态,补发 {} 并退出 forward",
match synth_event {
WorkflowEvent::WorkflowCompleted { .. } => "WorkflowCompleted",
_ => "WorkflowFailed",
}
);
break;
}
None => {
// 未命中(仍 running / 无记录 / 查询失败):重置累计计数继续等待
lagged_total = 0;
}
}
}
Err(RecvError::Closed) => break,
}
}
});
// 4. 后台异步执行 DAG,完成后更新执行记录状态
let event_bus = state.event_bus.clone();
let db = state.db.clone();
let exec_id = execution_id.clone();
let state_registry = state.workflow_state_registry.clone();
// F-260616-06 ②-2: move task_id / target_status 进闭包供完成/失败回调使用
let cb_task_id = task_id.clone();
let cb_target_status = target_status.clone();
// panic-guard:执行 spawn 闭包最外层包 catch_unwind(AssertUnwindSafe(..))。
// panic 时走已存在 failed 分支(update status=failed + emit WorkflowFailed + state_registry.remove),
// 防 panic 后终态不发/registry 不清永久卡。主逻辑/调度零改动,仅在 panic 时补发终态。
// 注:依赖 panic=unwind(tokio 默认),若构建设 panic=abort 则 catch_unwind 不生效(尽力兜底)。
tauri::async_runtime::spawn({
// panic 兜底需要这些引用:move 进 catch_unwind 闭包前各取一份 clone 供 panic 分支使用。
let panic_exec_id = exec_id.clone();
let panic_db = db.clone();
let panic_event_bus = event_bus.clone();
let panic_registry = state_registry.clone();
async move {
use std::panic::AssertUnwindSafe;
use futures::FutureExt;
// 原闭包主体包进 catch_unwind:panic 以 Err(Box<dyn Any>) 返回,Ok 走正常路径。
let outcome = AssertUnwindSafe(async move {
let mut executor = DagExecutor::new(event_bus.clone(), exec_id.clone());
// 注册执行器状态机:StateMachine 内部 Arc<Mutex>clone 共享底层 HashMap
// cancel_workflow_node IPC 经 execution_id 取此引用 set_cancelled,直达运行中 HumanNode
state_registry
.lock()
.await
.insert(exec_id.clone(), executor.state_machine());
let result = executor.run(&runtime_dag, config).await;
let (status, error) = match &result {
Ok(_) => ("completed", None),
Err(e) => ("failed", Some(format!("{:#}", e))),
};
// 更新执行记录(Repo 内部仅持有 Arc 连接,重建开销可忽略)
let workflows = WorkflowRepo::new(&db);
if let Err(e) = workflows.update_field(&exec_id, "status", status).await {
tracing::error!("更新工作流状态失败: {}", e);
}
if let Err(e) = workflows
.update_field(&exec_id, "completed_at", &now_millis())
.await
{
tracing::error!("更新工作流完成时间失败: {}", e);
}
// F-260616-06 ②-3 / ②-4: 工作流联动任务推进回调
// - 成功(completed): task_id 与 target_status 都 Some 时推进到 target_status
// - 失败(failed): 按 regression_target 推算退回态推进;无映射则跳过
// task_id / target_status 任一 None → 跳过(向后兼容,旧工作流不联动任务)
// 回调失败仅 tracing::warn!,不回滚(工作流成功语义与任务推进解耦 — 回调失败不撤销
// 已完成工作流,前端可后续手动处理)
match (cb_task_id.as_ref(), cb_target_status.as_deref()) {
(Some(tid), Some(target)) => {
let advance_target = match status {
"completed" => Some(target),
"failed" => regression_target(target),
_ => None,
};
if let Some(to) = advance_target {
let repo = TaskRepo::new(&db);
if let Err(e) = advance_task_atomic(&repo, tid, to).await {
tracing::warn!(
execution_id = %exec_id,
task_id = %tid,
target_status = %to,
workflow_status = %status,
"工作流联动推进任务失败(不回滚): {}",
e
);
} else {
tracing::info!(
execution_id = %exec_id,
task_id = %tid,
target_status = %to,
workflow_status = %status,
"工作流联动推进任务成功"
);
}
} else {
// failed 且无退回映射:warn 跳过(常见:target 是 todo 起点无可退态)
tracing::warn!(
execution_id = %exec_id,
task_id = %tid,
target_status = %target,
"工作流失败但 target_status 无退回映射,跳过任务回调"
);
}
}
_ => {}
}
// 执行结束(成功/失败)清理状态注册表,防内存泄漏
state_registry.lock().await.remove(&exec_id);
// 执行失败时补发 WorkflowFailed(执行器内部只发 NodeFailed
// failed_node 从状态机取首个失败/取消节点(R9⑪: 原 String::new() 空值)
if let Some(error) = error {
let failed_node = executor
.state_machine()
.snapshot()
.into_iter()
.find(|(_, s)| matches!(s, NodeStatus::Failed | NodeStatus::Cancelled))
.map(|(id, _)| id)
.unwrap_or_default();
event_bus
.send(WorkflowEvent::WorkflowFailed {
execution_id: exec_id.clone(),
error,
failed_node,
})
.await;
}
}) // 闭 catch_unwind 内 async move
.catch_unwind()
.await;
// panic 分支:catch_unwind 返回 Err(Box<dyn Any + Send>),走已存在 failed 清理路径。
match outcome {
Ok(_) => {}
Err(panic_payload) => {
// 提取 panic 消息(String/&'static str 常见,其他类型用兜底文案)
let msg = panic_payload
.downcast_ref::<String>()
.map(|s| s.clone())
.or_else(|| panic_payload.downcast_ref::<&'static str>().map(|s| s.to_string()))
.unwrap_or_else(|| "工作流执行 panic".to_string());
tracing::error!(
execution_id = %panic_exec_id,
"工作流执行 panic,走 failed 分支兜底: {}",
msg
);
let workflows = WorkflowRepo::new(&panic_db);
if let Err(e) = workflows.update_field(&panic_exec_id, "status", "failed").await {
tracing::error!("更新工作流状态失败(panic 兜底): {}", e);
}
if let Err(e) = workflows
.update_field(&panic_exec_id, "completed_at", &now_millis())
.await
{
tracing::error!("更新工作流完成时间失败(panic 兜底): {}", e);
}
panic_registry.lock().await.remove(&panic_exec_id);
panic_event_bus
.send(WorkflowEvent::WorkflowFailed {
execution_id: panic_exec_id.clone(),
error: msg,
failed_node: String::new(),
})
.await;
}
}
}
});
Ok(execution_id.to_string())
}
/// 列出工作流执行记录(最近 N 条,对标设计 §10.5 单用户桌面应用分页)。
///
/// M19:原实现调 `list_all()` 无 limit/分页,工作流执行记录随使用累积(workflow_executions 表
/// 只插不删,每次 run_workflow 落一条),长期使用后全量返回会撑爆前端列表 + IPC 传输 + 内存。
/// 加可选 `limit` 参数(默认 100,钳制 ≤ 500),list_all 已按 created_at DESC 排序,截断取最近 N 条。
/// 前端旧调用方不传 limit 走默认 100,零破坏(工作流历史本就按时间倒序展示,截断尾部老记录无感)。
#[tauri::command]
pub async fn list_workflow_executions(
state: State<'_, AppState>,
limit: Option<u32>,
) -> Result<Vec<WorkflowRecord>, String> {
let safe_limit = limit.unwrap_or(100).min(500) as usize;
let mut records = state.workflows.list_all().await.map_err(err_str)?;
if records.len() > safe_limit {
records.truncate(safe_limit);
}
Ok(records)
}
/// 按 ID 查询工作流执行记录
#[tauri::command]
pub async fn get_workflow_execution(
state: State<'_, AppState>,
id: String,
) -> Result<Option<WorkflowRecord>, String> {
state
.workflows
.get_by_id(&id)
.await
.map_err(err_str)
}
/// 发送人工审批响应
///
/// F-260615-01: 支持 single/multiple。
/// - single: decision 单值,decisions 为空(向后兼容);
/// - multiple: decisions 数组(≥1 项),decision 留空。
/// IPC 早校验规则与 HumanNode 下游兜底一致,避免「IPC 成功+工作流失败」割裂。
#[tauri::command]
pub async fn approve_human_approval(
app: AppHandle,
state: State<'_, AppState>,
execution_id: ExecutionId,
node_id: String,
decision: String,
// F-260615-01: 多选结果数组,缺省空数组(单选调用方不传)
decisions: Option<Vec<String>>,
comment: Option<String>,
// R-PD-5: options 由前端从收到的 HumanApprovalRequest 事件透传(IPC 无法访问节点 config)。
options: Vec<String>,
// F-260615-01: 选择类型,缺省 single
select_type: Option<String>,
) -> Result<(), String> {
let _ = &app; // 原签名含 app 参数(未使用),保留避免 invoke_handler 注册签名变化
let select_type = match select_type.as_deref() {
Some("multiple") => SelectType::Multiple,
_ => SelectType::Single,
};
let decisions = decisions.unwrap_or_default();
// 归一化决策集合(与 HumanNode 下游一致)
let mut picked: Vec<String> = decisions;
if picked.is_empty() && !decision.is_empty() {
picked.push(decision.clone());
}
// 数量校验
match select_type {
SelectType::Single if picked.len() != 1 => {
return Err("单选审批只能提交一个决策".to_string());
}
SelectType::Multiple if picked.is_empty() => {
return Err("多选审批至少提交一个决策".to_string());
}
_ => {}
}
// 每项非空 + ∈options(options 非空时)
for d in &picked {
if d.trim().is_empty() {
return Err("审批决策不能为空".to_string());
}
if !options.is_empty() && !options.contains(d) {
return Err(format!("审批决策非法: {}", d));
}
}
// 发送审批响应到事件总线(decision 取首项保留,decisions 透传全量)
let primary = picked.first().cloned().unwrap_or_default();
let response = HumanApprovalResponse {
execution_id: execution_id.clone(),
node_id: node_id.clone(),
decision: primary,
decisions: picked,
comment,
};
let event = WorkflowEvent::HumanApprovalResponse {
execution_id: response.execution_id,
node_id: response.node_id,
decision: response.decision,
decisions: response.decisions,
comment: response.comment,
};
// 直接发送到全局事件总线
state.event_bus.send(event).await;
Ok(())
}
/// 取消工作流节点(人工审批取消)
///
/// 从执行器状态机注册表取出共享引用,调 `set_cancelled` 置目标节点为 Cancelled。
/// StateMachine 内部 Arc<Mutex> 共享底层 HashMapIPC 写入直达运行中阻塞节点(HumanNode)
/// 的 select! 轮询分支,其 is_cancelled 检测到后返回 Err "人工审批被取消"。
///
/// 终态前置守卫:仅 Pending(排队中)/ Running(执行中,含阻塞等审批)允许取消;
/// 终态节点(Completed/Failed/Skipped/Cancelled)返 Err,避免静默覆盖终态。
/// 守卫放 IPC 层(非 set_cancelled 内):保留 set_cancelled 作为 executor 内部受控旁路语义,
/// 让状态变更的合法入口收敛到 IPC 这一道。
#[tauri::command]
pub async fn cancel_workflow_node(
state: State<'_, AppState>,
execution_id: ExecutionId,
node_id: String,
) -> Result<(), String> {
// 经 execution_id 取执行器状态机(与 NodeContext.node_status 共享同一 HashMap
let registry = state.workflow_state_registry.lock().await;
let sm = match registry.get(&execution_id) {
Some(sm) => sm,
None => return Err(format!("工作流 {} 不存在或已结束", execution_id)),
};
// 终态守卫:get() 返回缺失条目默认 Pending(尚未执行)→ 允许取消
let current = sm.get(&node_id);
match current {
NodeStatus::Pending | NodeStatus::Running | NodeStatus::Waiting => {
sm.set_cancelled(node_id.clone());
tracing::info!(
"工作流节点取消:execution_id={}, node_id={}",
execution_id,
node_id
);
Ok(())
}
// 终态:不允许取消(避免静默覆盖终态)
NodeStatus::Completed | NodeStatus::Failed | NodeStatus::Skipped | NodeStatus::Cancelled => {
Err(format!(
"节点 {} 已终态({}),无法取消",
node_id,
current.as_str()
))
}
}
}