Activepieces Action Runs 深度解析:MCP 与 Chat 工具背后的单步执行引擎
2026/9/13 16:43:14 网站建设 项目流程

Activepieces Action Runs 深度解析:MCP 与 Chat 工具背后的单步执行引擎

【免费下载链接】activepiecesAI Agents & MCPs & AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows & AI Agents • MCPs for AI Agents项目地址: https://gitcode.com/GitHub_Trending/ac/activepieces

导读

Action Runs(动作运行)是 Activepieces 中"脱离流程执行单个步骤"的核心机制:它既不创建临时流程,也不落库持久化,而是以同步请求/响应的方式,在引擎内直接执行一个 Piece Action 或 Code 步骤,并把{ status, output, logs, errorMessage }原样返回给调用方。它是 MCP 工具ap_run_actionap_execute_action/ap_run_code以及 Chat 工具背后的统一执行单元。读完本文,你将掌握 Action Runs 的完整调用链、端到端时限预算模型、"未启动(neverStarted)"语义、代码缓存命名空间与回收机制,以及它在限流、路由、RPC 超时等运维层面的全部边界条件。

本文以仓库内知识文档 action-run.md 为核心骨架,并结合 action-run.service.ts、execute-action.ts、action-run-cache.ts 等源码逐层展开印证。


一、从"临时流程 Hack"到真正的单步执行

在 Action Runs 出现之前,MCP/Chat 工具要执行一个孤立步骤,走的是所谓的"临时流程"(temporary flow)老路:

  1. 创建一个一次性(throwaway)流程;
  2. 把目标步骤"嫁接"进去;
  3. 调用flowRunService.test()触发运行;
  4. 轮询flow_run记录,最多等 120 秒;
  5. run.steps中把结果"抠"出来;
  6. 尽力删除临时流程。

这条路径存在明显问题:每次调用要付出 3 次数据库插入(流程、版本、运行记录)的成本,却因为flow_run.flowId配置了onDelete: CASCADE,删除流程时运行记录也随之清空——也就是说,它既慢又重,还没有留下任何持久化产物

Action Runs 正是为取代这套 Hack 而设计的新机制,核心特征如下:

  • 执行即返回:调用方在进程内直接拿到结果,不经过队列轮询;
  • 不持久化:目前阶段 Action Runs 是"纯执行",一切持久化能力(独立的action_run表、对应端点、"Action runs" UI 标签页)在后续版本单独落地,与 Flow Runs 分开管理。

从源码看,新版入口executeActionRunAction/executeActionRunCode位于 flow-run-utils.ts,它们把用户请求组装成一个标准的PieceAction/CodeAction步骤(固定步骤名step_1),再交给actionRunService.run()。这正是文档所说的"删掉了临时流程路径的重写"。


二、整体工作方式:三阶段架构

2.1 Dispatch:同步的用户交互任务

actionRunService(log).run({ projectId, platformId, step })是入口,流程如下(见 action-run.service.ts):

  1. 先用真实 schemaActionRunStep校验步骤(见下文"Schema 校验"一节);
  2. 若是 Piece 步骤,通过getPiecePackageWithoutArchive解析 piece 包;
  3. WorkerJobType.EXECUTE_ACTION提交任务,并同步等待响应——userInteractionWatcher.submitAndWaitForResponse是请求/响应模型,与属性解析(property resolution)、鉴权校验(auth validation)走的是同一套机制;
  4. 无轮询、无队列化流程任务、无重试

这与设计决策 Action runs dispatch as synchronous user-interaction jobs 完全一致。在 user-interaction-watcher.ts 中可以看到,任务以JobType.ONE_TIME入队,随后通过engineResponseWatcher.oneTimeListener等待requestId对应的响应;若等待超时返回空值,则抛出WORKER_DID_NOT_RESPOND_MESSAGE("Worker did not respond within the safety timeout")。

2.2 Engine:共享的单步原语

引擎侧的执行由actionOperation.execute(见 action.operation.ts)完成:

export const actionOperation = { execute: async (operation: ExecuteActionOperation): Promise<EngineResponse<ExecuteActionResponse>> => { const stepOutput = await actionRunStepRunner.run({ step: operation.step, operation }) const success = stepOutput.status === StepOutputStatus.SUCCEEDED return { status: EngineResponseStatus.OK, response: { success, input: stepOutput.input, output: stepOutput.output, message: success || isNil(stepOutput.errorMessage) ? undefined : String(stepOutput.errorMessage), }, } }, }

核心原语是 action-run-step-runner.ts:

export const actionRunStepRunner = { async run({ step, operation }: ActionRunStepParams): Promise<StepOutput> { const executionState = await flowExecutor.getExecutorForAction(step.type).handle({ action: step, executionState: FlowExecutorContext.empty(), constants: EngineConstants.fromExecuteActionInput(operation), }) return executionState.steps[step.name] }, }

要点在于:它针对空上下文FlowExecutorContext.empty()执行那一个步骤,然后从steps[step.name]取回结果。Chat 工具执行器(engine/src/lib/tools/index.ts)调用的正是同一个原语,因此"直接执行单个步骤"与"在流程里执行单个步骤"的行为保持了一致。

2.3 Outcome:结果归一化

deriveActionRunOutcome(见 action-run-outcome.ts)把引擎响应映射为{ status, output, logs, errorMessage }

  • statusFlowRunStatus类型,但 Action Run 是同步的,所以只有SUCCEEDED / FAILED / TIMEOUT / INTERNAL_ERROR可达——QUEUEDRUNNING永远不会出现,PAUSED被显式拒绝;
  • watcher 超时(WORKER_DID_NOT_RESPOND_MESSAGE)映射为TIMEOUT,其余异常映射为INTERNAL_ERROR
  • neverStarted字段单独透传(详见第四节)。

2.4 优先级:high,而不是 critical

Action Run 的作业优先级为high而非critical。这样做的原因是:action run 不应超过用户正实时等待的 Builder 交互任务critical留给人类正在等待的操作,high则保证 action run 排队在前、但不抢占交互。


三、调用链与状态机:一次 Action Run 的完整旅程

综合 dispatch、worker 与引擎三侧源码,一次完整调用链如下:

MCP ap_run_action / Chat ap_execute_action │ ▼ flow-run-utils.ts: executePieceActionRun / executeCodeActionRun │ 组装 PieceAction/CodeAction(step 名固定 step_1),UpdateActionRequest.safeParse 校验 ▼ action-run.service.ts: actionRunService.run │ ① ActionRunStep.safeParse 二次校验 │ ② getPiecePackageWithoutArchive 解析 piece(PIECE 类型) │ ③ 打上 expiresAt = now + AP_FLOW_TIMEOUT_SECONDS ▼ user-interaction-watcher.submitAndWaitForResponse │ 入队 JobType.ONE_TIME,带上 requestId / webserverId / schemaVersion ▼ worker: execute-action.ts │ ① resolveCodeStep(CODE 步骤:生成代码缓存命名空间) │ ② ctx.resolver.resolve(解析 pieces / codes) │ ③ timeoutInSeconds = ceil((expiresAt - now)/1000) │ ④ ctx.runtime.execute(EngineOperationType.EXECUTE_ACTION) ▼ engine: action.operation → action-run-step-runner │ FlowExecutorContext.empty() 上执行单步 ▼ sandbox.execute(SIGKILL 定时器在此武装) │ ⑤ 超时 → SandboxExecutionTimeoutParams(含 neverStarted) ▼ 引擎响应 → watcher 回调 → deriveActionRunOutcome → ActionRunResult

其中ExecuteActionJobDataWorkerJobType.EXECUTE_ACTION定义在 job-data.ts;EngineOperationType.EXECUTE_ACTIONExecuteActionOperation定义在 engine-operation.ts。由于EXECUTE_ACTION属于UserInteractionJobData,其载荷结构受LATEST_JOB_DATA_SCHEMA_VERSION约束——任何必填字段的增改都需要一次 job-data 迁移;expiresAt是特例,它被设计为可选字段,因此旧形状的作业仍能正常解析、行为不变。

Schema 校验:为什么不用z.custom

step参数用真实 schemaActionRunStep校验,而不是z.custom()。原因很直接:z.custom()在没有校验器时接受任何值——缺失的step、甚至42都能通过——这会让tryDequeue对该作业类型的 schema 闸门形同虚设。同一个 schema 在actionRunService入队前也会解析一次,因为:在出队时才发生 schema 失败会成为UnrecoverableError,而该路径永远不会向 watcher 发布响应——调用方会白白挂满整个预算,最后却被告知"动作可能已经执行"。


四、预算模型:一个端到端截止时间,而不是"两个超时的叠加"

这是 Action Runs 最容易被改坏的地方,文档专门用大段篇幅强调,这里完整复述其结论:

  • actionRunService在作业上盖戳expiresAt = now + AP_FLOW_TIMEOUT_SECONDS,随后等待expiresAt + 10sWATCHER_GRACE_MS = 10 * 1000,见 user-interaction-watcher.ts);
  • 这额外的 10 秒只覆盖沙箱杀进程与 pubsub 回程,不是给排队、解析或预置(provisioning)留的余量——因为这些阶段花的是同一个预算:createSandboxRuntime.execute会把运行时间钳制到预置和启动(boot)之后expiresAt剩下的部分,若已无剩余则直接抛SANDBOX_EXECUTION_TIMEOUT不启动引擎

不要把它重塑成"沙箱预算 + 容差因子"——这正是最初的 bug:watcher 的时钟从入队开始走,而沙箱的时钟从运行开始走,冷安装 piece(很容易超过 10s)会让调用方先超时,而动作仍在运行并写入数据,随之而来的重试还会复制副作用。

  • worker 侧不再自带上限:execute-action.tstimeoutInSeconds只从expiresAt推导Math.ceil((data.expiresAt - Date.now()) / 1000));
  • remainingTimeoutInSeconds必须在定时器武装的位置重新推导:唯一的执行预算强制者是sandbox.execute内部武装的 SIGKILL 定时器;早先版本在sandbox.start()之前就计算剩余时间,导致引擎从更晚的时刻起算拿到完整预算,用户代码一直运行到expiresAt + bootMs。启动(boot)不是尾部现象:canReuseSandboxSANDBOX_PROCESS默认返回 false(除非AP_REUSE_SANDBOX=true),所以每个生产 action run 都要付绑定重试、两次无界的 isolateexecPromise调用和 30 秒连接上限,远超 10 秒宽限。启动前检查只作为快速路径保留(跳过无意义的 spawn),权威钳制在sandboxStart超时块之后重算;注意重算的抛错落在try内部,会作废刚启动的沙箱而不是把它留作热箱——这是罕见路径上的一次冷启动代价,被接受为重构(把 boot 移出try)的替代方案。

层级顺序:预算栈必须保持有序

Action Run 是一个自动化步骤,所以它拿到的是流程步骤的预算——actionRunServicerunFlowAsTool读取同一个属性AP_FLOW_TIMEOUT_SECONDS。必须保持的顺序(由外到内):

worker RPC 超时(LONG_RUNNING_RPC_METHODS)= budget + LONG_RUNNING_RPC_MARGIN_MS > watcher 等待 = budget + WATCHER_GRACE_MS > action 预算 > 引擎运行

余量不是装饰:app 在同一次调用上还会花费 action 之外的时长(例如pieceInputFiller的模型调用,它没有自己的超时),所以零余量的截止时间会在 action 仍合法运行时就把调用方掐死。任何一层都不要给它单独的 env var——整个栈随AP_FLOW_TIMEOUT_SECONDS这一个旋钮整体移动。

不要贸然给预置或连接等待加时限

spawnWithKilltimeoutMswaitForConnection的 30 秒字面量(bare literal)套截止时间,看起来是顺理成章的下一步,但两者抛出的都是普通Error,无法通过execute-action.ts里的isSandboxTimeout判定,会被重新抛出——调用方得到的是INTERNAL_ERROR/ "the engine crashed while loading or executing the piece",这正是下一节要禁止的错误误报,而不是诚实的neverStarted。而且给连接等待设上限也约束不了 boot:它是 boot 三阶段中的最后一环,前面还有无界的 isolate 调用。两个改动都买不到安全,因为预置超限本来就会以"未执行任何用户代码"的neverStarted结束。


五、neverStarted:把 TIMEOUT 一分为二的"未写证明"

5.1 为什么 TIMEOUT 本身不够

"动作运行了、我们失去了耐心"和"什么都没执行"绝不能共享同一条消息。只有后者可以安全地盲目重试;而告诉 Agent"去检查一下有没有写入"——当它什么都没运行时——会训练 Agent 放弃无操作。两条来源汇入同一个标志:

  • worker 侧:拒绝启动一个截止时间已过的运行(SandboxExecutionTimeoutParams.neverStarted);
  • watcher 超时:actionRunService调用jobQueue.cancelAndReportNeverStarted

在 action-run.service.ts 中可以看到:仅当结果状态为TIMEOUT且 watcher 无数据时,abandonedWithoutStarting才会尝试调用cancelAndReportNeverStarted,并把结果并入neverStarted。在 MCP 工具侧(flow-run-utils.ts),TIMEOUT + neverStarted会给出"never started — nothing ran and nothing was written. Safe to retry as-is",而裸TIMEOUT则提示"may have partially completed, so do not re-run it blindly"。

按文档的说法:neverStarted是**"未写入"的证明**(proof of no-write),sound 但刻意不完整——它不是生命周期阶段。

5.2 移除与上报是两件事:用processedOn,不用job.getState()

cancelAndReportNeverStarted总是先尝试移除作业,并从job.processedOn读取信号——绝不从job.getState()读取。早期形态把两者焊在一起:一个状态允许列表同时把关"销毁作业,使其无法再写入"和"告诉 Agent 什么都没运行"。但BullMQ 状态不是单调的——waiting → active → delayed → waiting是合法路径——一个已经执行并写入的作业可能因为 worker 断连(详见 Workers)落在delayed状态、被读成"尚未开始",焊接版本于是把它移除并上报neverStarted: true,告诉 Agent"什么都没运行、什么都没写入、可以原样重试"——这正是"无重试"决策要防止的重复副作用。

processedOn是可靠的信号,并且压倒了状态允许列表,所以不要两者都保留

  • moveToActive在同一脚本里设置processedOn
  • 该路径上没有任何操作清除它(moveToDelayedmoveStalledJobsToWait都不碰它;只有Job.retry()会置空——那是显式的失败作业重试 API);
  • 每个终态都只能经过active到达,所以completed/failed一定携带processedOn

状态读取在竞态窗口内多一次往返,且无法独立触发。

5.3 移除失败作为第二信号

唯一processedOn覆盖不了的情况,是作业在"读取与移除之间"被抢走。经 pinned 的 bullmq 5.61.0 验证:

  • job.remove()只在作业被其他 worker 锁定时抛错removeJob脚本在锁 key 存在时返回 0,Job.remove对 falsy 结果抛错);
  • completed/failed状态成功移除。

因此抛出即意味着"正在运行中"。

5.4 残余风险(已复核并接受)

processedOn来自getJob()快照,所以一个在快照与remove()之间"出队并完成"的作业仍会报neverStarted: true。不要把"一个 Redis RTT 内"当作边界:快照会在 API 事件循环停滞期间过期。真实的前提栈是:作业在完整的约 130 秒 watcher 预算内饿死未出队 → worker 恰好在该过期窗口内出队 → 分发 + 沙箱 + 执行 +completeJob全部在removeJob于 Redis 上执行之前完成。冷生产启动是秒级的,这让它在生产上不可达;在AP_REUSE_SANDBOX=true下,一个几百毫秒的 GC 停滞加上完美时序可能触发——即使在宽裕假设下,量级也约为≤1/10⁷ 次 action run。文档注明该残余被保留,因为任何缓解方案都比暴露成本高;已知的廉价闭合方案(若未来需要消灭它):让completeJobmoveToCompleted之前写一个短 TTL 的、以requestId为 key 的完成回执,cancelAndReportNeverStarted在成功移除后检查它——"已移除、无processedOn、无回执"即证明未完成。

代价processedOn标记的是"已出队"而非"已启动"——app 在 worker 收到作业之前就拥有它——所以一个被轮询后因断连成为孤儿的作业会报neverStarted: false(虽然什么都没运行)。这在部署期间最可能发生,而恰恰是那时诚实的回答最有价值。这是刻意接受的取舍:一次虚假的"检查写入"只花 Agent 一次查询,一次虚假的"可安全重试"则让用户付出一次重复写入的代价。


六、代码缓存:action-runs/ _ 命名空间与回收

6.1 为什么需要独立命名空间

代码缓存(code cache)原本以flowVersionId+ 步骤名作为 key,而 action run 两者都没有。若直接用DEFAULT_MCP_DATA加上固定步骤名step_1,会踩进 代码缓存按 flowVersionId 命名空间的坑:跨租户共享一个目录,且在未开启AP_REUSE_SANDBOX=true时 isolate 模式根本没有/root/codes挂载。

execute-action.tsresolveCodeStep通过actionRunCache.namespace推导一次命名空间,并把它交给三处:

  1. CodeArtifact(构建产物);
  2. provision.flowVersionId(构建挂载);
  3. EngineConstants.flowVersionId(引擎读取的 key)。

命名空间格式为action-runs/<platformId>_<sha256(sourceCode)>

  • 内容哈希保证code-builder的目录内哈希检查永不失配,重复片段可跳过bun install
  • platformId让目录可归属到具体平台;
  • 设计依据见决策 Action-run code caches live in their own directory。

action-runs/这一级目录是承重的:它把 action-run 构建与 flow-version 构建分开(platformIdflowVersionId都是 21 字符apId,靠长度分不开),并且是整个清理器(sweeper)的唯一作用域。

6.2 路径安全:assertSafeCodeNamespace

命名空间带有/,所以由assertSafeCodeNamespace守卫,而不是assertSafePathSegment。嵌套无法对引擎隐藏:在 fork 模式下AP_BASE_CODE_DIRECTORY是宿主机codes/原始路径、没有挂载间接层,引擎从宿主机文件系统直接读<codes>/<namespace>/<stepName>/index.js,它持有的命名空间必须真的包含action-runs/assertSafeCodeNamespace/切分、拒绝超过两段、并把每段交给未修改的assertSafePathSegment——这样用于 bind-mounthostPath的穿越规则只在一个地方声明。stepNameplatformId仍是单段,仍直接使用assertSafePathSegment,不要放宽。两个合法段意味着a/b现在能通过(过去会被拒)——这是刻意的,残余风险是命名空间落深一层,而非逃逸。

6.3 清理器的作用域:连codes/根都够不到

新的目录级隔离比它取代的ar_前缀更强:

  • 前缀方案依赖ALPHABET(见 id-generator.ts,核心 utils 中ALPHABET不含_)来保证词汇级不冲突——一旦ALPHABET加入_,任何以ar_开头的apId(约 1/238000,规模化后几乎必然出现)都会被误分类,清理器会静默删除流程缓存;
  • 现在一个 flow-version 缓存只有落在codes/action-runs/内部才可能被触及,这需要ALPHABET加入-ID_LENGTH从 21 变 11ApId正则改变——三者同时发生才行;
  • sweep不再按名字过滤:它只读自己的目录,因此codes/根下的任何东西无论多老都存活。

该行为由测试钉死:测试种子一个 flow-version 目录、一个遗留ar_前缀目录和根下一个散落文件,断言三者全部存活。

6.4 内容寻址消灭了唯一的 GC,所以清理不可省略

当命名空间是常量DEFAULT_MCP_DATA.flowVersionId时,所有 action run 编译进一个目录,不同片段必然哈希未命中——而installFn通过rm -rf目录来"重建",这种破坏性重建本身就是回收,上界为 O(1) 个目录。把路径改为按片段唯一,修好了并发竞态和缺失挂载,代价是那条分支不可达、GC 随之消失actionRunCache.sweep(见 action-run-cache.ts)取而代之:

参数说明
ACTION_RUN_CACHE_TTL_MS2 小时codes/action-runs/下未触碰的子目录被移除
ACTION_RUN_CACHE_MAX_DIRS200存活目录数超过上限后,按最旧优先驱逐
ACTION_RUN_CACHE_SWEEP_INTERVAL_MS30 分钟worker 本地的清理间隔
ACTION_RUN_CACHE_FIRST_SWEEP_DELAY_MS60 秒首次清理的延迟
ACTION_RUN_CACHE_ACTIVE_WINDOW_MS15 分钟活跃窗口,比它新的目录豁免驱逐

localExecutionCache.provision在沙箱启动前touch每个目录的 mtime(以isActionRunNamespace为门控,flow-version 目录零开销)——这也是清理无竞态的原因:当前正在 bind-mount 的目录必然在约 130 秒内被触碰过,远在 TTL 之内。

6.5 touch 落在清理器 re-stat 之后怎么办:settlePendingRemoval

removeDir内部的 mtime 复查只能排除在 touch 之前开始的移除;已经过了 re-stat 的那次会在一棵刚被沙箱接受为缓存命中的目录下删树,导致运行因缺失index.js而死。removeDir在启动rm同一个同步块里把 promise 发布到pendingRemovals(在任何await之前——这正是两道检查完备的原因),installCodeStep在 touch 之后调用actionRunCache.settlePendingRemoval,等待进行中的移除并在确有移除时重建步骤。该守卫是进程本地的;两个 worker 进程共享一个挂载时仍只依赖 mtime 复查。

6.6 按目录数而非字节数回收:两个安全属性

  • 计数上限把存活下限钉在 N,无论目录多重;字节预算会让存活数随体积浮动——0.5GB 一个目录、2GB 预算下只剩 4 个存活,少于活跃集,必然驱逐到活跃集内部;
  • 但仅靠上限不够且容易自我说服:mtime 是预置时间、运行期间(≤120s)不刷新,慢 action run 会被每个晚于它启动的快运行压过,"最新的 200 个"不是"活着的 200 个"。触达一个活跃目录只需在一个执行窗口内预置ACTION_RUN_CACHE_MAX_DIRS个不同片段——约 1.7 个/秒,云端规模可达;
  • ACTION_RUN_CACHE_ACTIVE_WINDOW_MS(15 分钟)封住它:驱逐跳过任何比该窗口新的目录,所以当树全活跃时,它宁可保持在容量之上直到老化——磁盘超量而不是运行死亡,且 sweep 记录activeCount,让永久阻塞的驱逐不会无声无息。

运维不变量:保持ACTION_RUN_CACHE_MAX_DIRS>AP_WORKER_CONCURRENCY× 共享挂载的副本数(参考拓扑是 25 对 200)。低于该值,窗口会阻塞所有驱逐、上限失效。字节预算为何被试验后移除,见决策 000016。

6.7 收敛而非协调:worker 无法加锁

N 个清理器共享一个./cache是稳态(compose 是replicas: 5;Helm 默认rollout把一块 RWO PVC 挂进每个副本),而 worker 进程没有 Redis,所以distributedLock和系统作业都不可用(参见 Workers 的 Gotchas)。安全靠结构:

  • 每步幂等(force: true、ENOENT 容忍、rm前立即复查 mtime);
  • 驱逐从实时readdir重算目标,而不是跨删除累积——两个同时运行的清理器会挑同一组最旧目录、只删一次,谁也不会驱逐过界。

不要添加跨删除累积的状态——被移除的字节记账正是这种东西,同伴先删一个目录时它就会过度驱逐。定时抖动也没有必要:并发 sweep 只是多花几次重复stat调用。

6.8 历史布局目录故意泄漏

sha256mcp-flow-version-idar_前缀目录只存在于运行过引入它们的分支中间提交的机器上——这些布局从未进入main,所以现场没有可迁移的东西,也没写回收路径。清理器回收它们:做过名字嗅探的分支没有 TTL 和 mtime 复查,而mcp-flow-version-idmain上仍是活常量(DEFAULT_MCP_DATA.flowVersionId),某天有代码在它下面预置时,那个分支每 30 分钟就会rm -rf一次。在跑过这些提交的开发机上,rm -rf cache/v12/codes就是清理方式。

6.9 缓存命中必须证明产物存在

cacheState的 memo 是模块级作用域、无失效 API,命中时不碰磁盘。删掉步骤目录后 memo 仍报cacheHit: true,什么都不重建,引擎require缺失的index.js——该片段在该 worker 上一直失败到进程重启。进程内失效也不够:参考 docker-compose.yml 给app和 5 个worker副本共享同一个./cachebind mount,一个容器的清理器删除的目录可能已被另一个容器 memo 化。每次命中的那一次stat,是让删除(清理器、运维、重置卷)可恢复的唯一机制——不要把code-builder里的compiledArtifactPresent检查"简化"掉。

6.10 活跃窗口必须覆盖运行预算

ACTION_RUN_CACHE_ACTIVE_WINDOW_MS(15 分钟)豁免新目录,而 mtime 在预置时盖章、运行期间不刷新——所以比窗口更长的运行会在执行中老化出自己的豁免;若树超过ACTION_RUN_CACHE_MAX_DIRS,清理器会删除它脚下的目录,导致运行因缺失index.js失败。预算还是硬 120 秒时不可达;预算跟随AP_FLOW_TIMEOUT_SECONDS后即可达——而运维确实会把它设到 15 分钟以上。sweep接收activeWindowMs,worker 传max(window, budget)。只有 action run 暴露此风险——清理器的整个作用域是codes/action-runs/,flow-version 代码缓存不受影响。


七、actionRunMode:两种被禁用的"仅流程"行为

EngineConstants.fromExecuteActionInput构造引擎常量时置入actionRunMode(见 engine-constants.ts),它禁用 piece-executor.ts 中的两种仅流程行为:

  1. 进度上报器变 no-op:没有 flow run 可流式上报;
  2. waitpoint 被拒绝assertActionRunCannotSuspend抛普通Error(USER 级),步骤以FAILED结束而非INTERNAL_ERROR——"该操作只在流程内有效"是使用错误,不是引擎 bug,不应触发 oncall。

7.1 createWaitpointHook 必须同步抛出

createWaitpointHook必须从返回函数体同步抛出,不能收进单个async闭包——这是 hook 拆成同步包装 +submitWaitpoint的原因。废弃的pause()shim(versioning.ts 的buildLegacyPauseHook,保留至 2026-10-12)做context.run.createWaitpoint({...}).catch(() => process.exit(1)):若拒绝以 rejected promise 到达,那个.catch会挂上并杀死 worker 进程;同步抛出则传播为 FAILED 步骤。仍在使用context.run.pause()的 piece 都会命中此路径。

7.2 FLOW 作用域的 store 冲突:已知、被接受

context.storecontext.files是 HTTP 支撑的服务,只需要internalApiUrlengineTokenflowId。没有真实流程时,fromExecuteActionInputDEFAULT_MCP_DATA哨兵flowId: 'mcp-flow-id'替代,createContextStore把 FLOW key 构造成prefix + 'flow_' + flowId + '/' + key。这不是租户破坏:storeEntryController用引擎 token 钉住projectId,条目永不跨项目。它是键冲突——项目内所有 action run 的 FLOW 作用域store.put()都落进同一个flow_mcp-flow-id/命名空间。PROJECT 作用域条目正确,context.files忽略flowId,上传干净地按项目隔离。修复意味着要么在actionRunMode拒绝 FLOW 作用域(会破坏把存储当副作用的 piece),要么给每次运行一个独立flowId(让那些写入变成不可达垃圾)——两者都不明显正确,故维持现状。


八、运维边界:限流、路由、RPC 与 socket.io

  • EXECUTE_ACTION豁免项目并发限制器RATE_LIMIT_WORKER_JOB_TYPES只有[EXECUTE_FLOW],且rate-limiter-interceptorAP_PROJECT_RATE_LIMITER_ENABLED(默认false)为门控。因此一个 action run 在整个预算期内占用一个WORKER_JOBS槽、没有项目级上限——通过 MCPap_run_action驱动的慢端点可以把实例上的所有槽占满这么久。把EXECUTE_ACTION加进列表不是一行改动:shouldContinue收窄到ExecuteFlowJobData并读取environment,而ExecuteActionJobData没有该字段。

  • EXECUTE_ACTION不可按项目组路由PROJECT_GROUP_ROUTABLE_JOB_TYPES{EXECUTE_FLOW, EXECUTE_WEBHOOK},所以即使项目有专属 worker,action run 也落在平台/共享队列上——与被取代的临时流程路径(按EXECUTE_FLOW路由)不同。当项目的专属 worker 是唯一能触达其网络的 worker 时,这一点很关键。

  • handler threw前缀不能丢createConfiguredPieceTools靠 grep 这个前缀在"该动作失败"与"它可能已运行,不要再调用"之间选择——后者正是阻止 Agent 重跑一个看不到结果的副作用。

  • socket.io 的 ack 超时是按调用的socket.timeout(ms)设置flags.timeout_registerAckCallback读它,emit随后清掉flags——所以createRpcClient接受RpcTimeout = number | ((method: string) => number)就是完整修复,且只在 worker 侧。曾构建并回滚过一种延迟变体(立即 ack,稍后通过按callId键控的rpc-result事件投递结果):它一无所获、需要两端同时升级,还引入了一个崩溃——结果 promise 在 ack 解析前没有处理器,ack 窗口内的断连会以未处理拒绝抛出,Node 默认的--unhandled-rejections=throw会杀死 worker(在WORKER_AND_APP容器里,经 docker-entrypoint.sh 把整个实例带崩)。断连本来就有免费处理:Socket.onclose调用_clearAcks,用 "socket has been disconnected" 拒绝每个emitWithAckack。

  • 预算上限抬高史:Issue #15127 声称 0.86 版就在AP_FLOW_TIMEOUT_SECONDS下运行 agent 工具,历史不支持该说法。runPieceTool与配置 piece 工具路径在 #14613 引入;之前 agent 的 piece 动作经 MCP 客户端进入actionRunService,同样是 120s。0.88 增加的第二道更低上限(worker 内 60s RPC 超时)让新功能一出生就不可用。在 changelog 里写"我们只是恢复了 0.86"之前值得知道这些。


九、版次与适用面

所有版次(Community、Enterprise、Cloud)都支持 Action Runs。其中:

  • MCP 的ap_run_action属于CE(社区版)
  • 使用它的 Chat 工具属于EE(企业版)

十、关键文件索引

关注点路径
入口:actionRunService.run()、结果映射packages/server/api/src/app/action-run/(action-run.service.tsaction-run-outcome.ts
同步等待机制:submitAndWaitForResponse(带可选的按调用方超时)packages/server/api/src/app/workers/user-interaction-watcher.ts
MCP 工具层:executeActionRunAction/executeActionRunCode(删除临时流程路径的重写)packages/server/api/src/app/mcp/tools/flow-run-utils.ts
引擎操作定义:EngineOperationType.EXECUTE_ACTIONExecuteActionOperationpackages/core/execution/src/lib/engine/engine-operation.ts
作业数据定义:WorkerJobType.EXECUTE_ACTIONExecuteActionJobDatapackages/core/execution/src/lib/workers/job-data.ts
共享单步原语packages/server/engine/src/lib/handler/action-run-step-runner.ts
动作操作执行packages/server/engine/src/lib/operations/action.operation.ts
引擎常量:actionRunModefromExecuteActionInputpackages/server/engine/src/lib/handler/context/engine-constants.ts
actionRunMode守卫packages/server/engine/src/lib/handler/piece-executor.ts
worker 处理器:execute-action.ts(超时推导、沙箱超时 → TIMEOUT)packages/server/worker/src/lib/execute/jobs/execute-action.ts
代码缓存:namespace/isActionRunNamespace/touch/settlePendingRemoval/sweeppackages/server/sandbox/src/lib/cache/action-run-cache.ts
缓存路径布局(v15/布局权威,ACTION_RUN_CODE_DIRpackages/server/sandbox/src/lib/cache/cache-paths.ts
清理驱动:30 分钟间隔packages/server/worker/src/lib/worker.ts(startCacheSweeper/stopCacheSweeper
沙箱:remainingTimeoutInSeconds、启动后截止时间钳制packages/server/sandbox/src/lib/sandbox.ts

结语

Action Runs 是 Activepieces 中"同步执行单个步骤"的基石:它用userInteractionWatcher的请求/响应模型取代了临时流程的建-跑-轮询-删,用单一的AP_FLOW_TIMEOUT_SECONDS端到端预算取代了多段超时叠加,用neverStarted把"超时"拆成可安全重试与不可盲目重试两种语义,并用action-runs/<platformId>_<sha256>的内容寻址命名空间与幂等清理器,为这个无flowVersionId的执行路径补齐了代码缓存的安全边界。理解这些设计,不仅能让 MCP 工具、Chat 工具与引擎侧的二次开发有的放矢,也能在调优AP_FLOW_TIMEOUT_SECONDS、部署多 worker 拓扑或排查"超时后到底跑没跑"时,直接对应当前仓库的实现证据。

【免费下载链接】activepiecesAI Agents & MCPs & AI Workflow Automation • (~400 MCP servers for AI agents) • AI Automation / AI Agent with MCPs • AI Workflows & AI Agents • MCPs for AI Agents项目地址: https://gitcode.com/GitHub_Trending/ac/activepieces

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询