Plan 预览确认与 HITL
适用端:后端 + Web | 类型:机制
定制功能
「AI 起草一整份流程 → 人看清它将会改什么 → 人点确认 → 才真跑」这条链路,以及运行中途插一脚的 hitl.* 节点、发布前的 V 系列闸门、失败后的 Saga 逆序补偿——整套都是本项目自研,上游 yudao 没有任何对应物。
上游的 AI 模块只有「对话」和「绘图」,它压根没有「让模型产出可执行流程」这个概念,自然也没有「产出之后先给人看」这一环。
这一页有三样东西,别混成一样
| 它是什么 | 什么时候发生 | 谁在看 | |
|---|---|---|---|
| Plan(草稿卡) | 一整份 Workflow DSL | 生成后、发布前 | 发起需求的那个人 |
| 变更清单 | 「点了同意之后平台上会多出/改掉什么」 | 与 Plan 同屏,也独立出现在审批页 | 确认者 / 审批人 |
| HITL 闸口 | 运行中途挂起,等人决定走哪条腿 | 流程已经在跑的时候 | 被指派的审批人 |
三者共用「让人先看一眼」这个动机,但代码路径、数据载体、失败语义完全不同。变更清单尤其容易被当成 Plan 的一部分——它不是:Plan 说的是「流程长什么样」,清单说的是「跑完之后世界有什么不同」。
定位:讲清楚从「一句需求」到「平台上真的多了一张表」中间,人一共有几次机会拦下来,每次拦的是什么、拦不住的是什么。
前置阅读:Agent 架构总览(声明值 vs 生效值、架构测试)、工作流引擎(DSL 结构、编译器、checkpoint)、工具体系(风险等级、六步网关)。
它解决什么问题
AI 生成的流程里可能有 datamodel_create——一条 DDL,不可回滚。如果「确认并运行」是一个按钮而不是一屏影响面,那么用户点下去之前对「会多出一张什么样的表」是零信息的。
三道防线,每一道都只解决前一道解决不了的事:
- 规划期——提示词与校验器让模型别产出坏 DSL。但它管不住「DSL 完全合法、跑起来却建了一张用户没想要的表」。
- 确认期——变更清单把「将会发生什么」翻译成中文给人看,发布闸门拦住体检不过的。但它管不住「清单本身算漏了一块」。
- 运行期——
hitl.*节点让流程停在半路等人。但它只在作者预先想到要停的地方才停。
三道防线之间没有令牌
草稿卡「确认并运行」是两次独立的 HTTP 请求:先 POST /agent/planner/publish-workflow(凭 code),再 POST /agent/workflow/run-stream(同样凭 code)。
中间没有 plan token、没有 hash、没有 manifest 版本号。也就是说:用户看到的清单与真正跑起来的那份 DSL,靠的是「两次请求之间没人改库」这个假设,而不是任何机制。
运行期的 HITL 闸口卡稍好一点——它凭 executionId + casInterruptedToRunning 做原子认领,同一执行不会被双跑。但那防的是并发,不是内容漂移。
代码在哪
后端:manifest/ 是这一页最大的一块
framework/agent/core/manifest/——27 个文件、6178 行。前 10 大:
| 文件 | 行数 | 它负责什么 |
|---|---|---|
ChangeManifestDryRunner | 1122 | 全清单唯一允许查库的地方,只调只读接口 |
UntrustedInputTracer | 977 | 填另一张正交表:「喂进去的值有多少是别人能写的」 |
WorkflowChangeManifestBuilder | 666 | 主入口,做「与具体工具无关」的那一半 |
ChangeManifest | 592 | 载荷本体,8 个嵌套类 |
ManifestArgs | 312 | 参数摘要(截断、脱敏) |
FormUpdateChangeExtractor | 276 | form_update 的专用抽取器 |
FormDataArgs | 217 | 写业务数据那几个工具共用的参数解析 |
ChangeItem | 193 | 清单里的一项,三方各填一部分字段 |
ManifestScoper | 179 | 决定哪几项给人看(裁剪) |
FormPublishBundleChangeExtractor | 142 | form_publish_bundle 的专用抽取器 |
剩下 17 个是 11 个工具专用抽取器 + ChangeKind + DryRunResult + ManifestLabels + ManifestFieldRows + ManifestContext + ToolChangeExtractorRegistry。
后端:另外四处
| 位置 | 行数 | 说明 |
|---|---|---|
framework/agent/core/plan/WorkflowDslPlannerPrompt | 1170 | core/plan/ 只有这一个文件——全是提示词 |
service/plan/WorkflowPlannerServiceImpl | 604 | 生成-校验-自修正循环 |
controller/admin/planner/AgentWorkflowPlannerController | 214 | 3 个端点 |
service/workflow/AgentWorkflowCompensator | 228 | Saga 逆序补偿 |
service/workflow/AgentBpmApprovalService | 197 | BPM 重审批 + 清单冻结 |
listener/AgentBpmApprovalListener | 109 | 审批完成 → 广播唤醒 |
explain/ 与 copy/ 不在 Plan 预览链路上
framework/agent/core/explain/ProcessNodeTreeExplainer(266 行)与 framework/agent/core/copy/WorkflowCopyRewriter(530 行)各是它们目录里唯一的文件,名字看着都像「给人看流程长什么样」,但都不是变更清单的渲染件:
ProcessNodeTreeExplainer服务的是process_create_and_deploy的审批流节点树解释;WorkflowCopyRewriter服务的是「复制一条流程」时改写冲突项。
找清单的渲染逻辑请去 ChangeManifestCard.vue,找 headline 的拼法请去 ChangeKind。
前端
| 文件 | 行数 | 说明 |
|---|---|---|
views/agent/assistant/components/AssistantChat.vue | 2150 | 聊天主面板,草稿卡与 HITL 卡的宿主 |
views/agent/workflow/components/ChangeManifestCard.vue | 1014 | 清单渲染,四处宿主复用 |
views/agent/workflow/editor/index.vue | 1114 | 设计器(含「影响面」面板) |
views/agent/assistant/components/WorkflowDraftCard.vue | 388 | 草稿卡 |
views/agent/assistant/components/HitlConfirmCard.vue | 196 | 运行期轻确认卡 |
views/agent/workflow/publishGate.ts | 124 | 发布预检三态判定 |
views/agent/workflow/components/PublishGateDialog.vue | 105 | 被闸门拦下时的逐条清单 |
views/agent/workflow/components/ManifestDryRunTag.vue | 65 | 干跑三态压成一颗内联标签 |
ChangeManifestCard.vue 的四处宿主(写在它自己的文件头 :4):聊天 HITL 确认卡、AI 助手草稿卡、BPM 审批详情 + 通过弹层、设计器「影响面」面板。
核心机制
一、Plan 就是一整份 DSL
线性 planner(AgentPlannerService / AgentPlanService,产出「步骤列表 + 逐项勾选」)已删除。AgentPlanProperties 的类注释记着这次删除连带移除了 expireMinutes / maxPlanRounds / toolResultMaxChars 三项配置。
于是现在的 Plan 产物是一个 DAG,不是步骤列表。这直接决定了前端一个刻意的取舍(WorkflowDraftCard.vue:100-103):
<!-- 只读节点预览:plan-as-workflow 产出的是一整份 DSL(DAG),这里列出节点顺序与类型。
刻意没有勾选框 —— 与线性 plan 的「逐项勾选执行」不同,Workflow 是一个整体,确认即整份运行。 -->确认即整份运行——没有「只跑前三个节点」这种中间态。
草稿的 code 由服务端生成,不信任模型给的占位 id(WorkflowPlannerServiceImpl:483-487):
/** 生成唯一 code:{@code wf_plan_<时间戳base36>_<序号base36>},匹配 ^[a-z][a-z0-9_]{0,62}$ */
private String generateCode() {
return "wf_plan_" + Long.toString(System.currentTimeMillis(), 36)
+ "_" + Long.toString(CODE_SEQ.incrementAndGet() % 46656, 36);
}这个 code 是「确认并运行」唯一的凭据
publish-workflow 与 run-stream 都只认它。它由服务端生成、落在 agent_workflow_def.code 上,模型碰不到——但任何持有这个 code 且握有 agent:assistant:use 权限的请求都能发布它。这是「没有令牌」那句话的具体含义。
二、生成-校验-自修正循环
WorkflowPlannerServiceImpl.planToWorkflow 的骨架(:127-207):
for (int round = 1; round <= maxRounds; round++) {
CallResult call = callModel(systemPrompt, history, context, trimmed, round, taskKey);
if (call.resp() == null) { /* 调用本身失败:回灌「与你格式无关,请原样重出」*/ continue; }
String content = StrUtil.trimToEmpty(resp.getContent());
if (content.equals(previousRejected)) { /* 🔴 定点检测:提前止损 */ throw ...; }
previousRejected = content;
ParseOutcome outcome = parseDsl(content, resp.getUsage(), history); // ① 抠 JSON
if (outcome.def() == null) { lastFeedback = outcome.feedback(); continue; }
AgentValidateResult result = validateTwoLayer(def, context); // ② 双层校验
if (!result.isPassed()) { /* 回灌 toFeedback() */ continue; }
return buildDraft(def, result, round); // ③ 通过
}
throw exception(WORKFLOW_PLAN_DSL_GENERATION_FAILED, ...); // 🔴 超限 fail-close四个要点:
1. 轮次预算 = 6,每轮一次真实计费。 AgentPlanProperties.asWorkflowMaxRounds = 6。类注释的纪律是「真不稳时的做法是改 AI 模型配置列表的排序值,而不是无限加轮次」。
2. 定点检测是给「原地打转」止损的。 WorkflowPlannerServiceImpl:150-153 逐字:
// 🔴 定点检测:与上一轮**一字不差**,说明回灌压根没被采纳,再问一轮只会得到同一份东西。
// 温度 0.1 下这是硬定点而不是巧合。实测疤:一次建表编排第 3~6 轮的原文 md5 完全相同
// (7808df6b…),白烧 4 轮、约 6.4 万 prompt token,最后抛出去的还是那句没有信息量的
// 「6 轮内未能产出结构合法的 DSL」。轮次预算是给「改一处、再试」用的,不是给原地重复用的。抛出的那句话里带着处置路径(:155-161):不是「轮次不够」,是「该换模型」,并且写明编排用的是「AI 模型配置里已启用 + 排序值最前」的那个。
3. 超限 fail-close。 :202 逐字:「绝不拿最后一轮那份非法 DSL 凑合(与线性 planner、agent_loop 同一条纪律)」。
4. 两处污点围栏。 规划器这一处比问答那几处更要紧,理由写在 :101-106:
// 🔴 B2-3:用户这句需求是 TRIGGER_CHAT 污点源,包成数据段。
// 规划器这一处比问答那几处更要紧:它的产物是一份<b>会被落库、还会被发布</b>的 DSL。
// 一句藏在需求里的「顺带把某张表的数据清掉」如果被当成指令读进去,
// 它就会变成一个真节点、过校验、然后只等人在确认卡上点「确认并运行」。
// 变更清单与审批闸是后面的防线,但最省事的位置是<b>不让它进 DSL</b>。第二处在 :186-194——校验反馈也要包成数据段,因为它回显了模型写的标识符:
// 🔴 B2-3:校验反馈要包成数据段。它看着是「平台自己算出来的」,但里面<b>回显了模型写的标识符</b> ——
// 节点名、表名、工具入参都会被原样拼进那句「XX 不合法」。
// 于是「节点名里写一句注入」就能让它以校验器口吻出现在回灌消息里,
// 而回灌消息是模型<b>最信</b>的一段(它刚被拒了一次,正等着平台告诉它怎么改)。三、变更清单:五方各填一块
WorkflowChangeManifestBuilder 的类注释(:36-70)把参与者讲得很清楚,本页按它的口径重述:
| 参与者 | 它填什么 | 判据 |
|---|---|---|
| Builder 自己 | 遍历节点、认工具、取风险、算可达性、汇总计数、拼 headline | 「与具体工具无关」的那一半 |
11 个 ToolChangeExtractor | 「这个工具的参数意味着什么」 | 一个工具一个实现 |
ChangeManifestDryRunner | 「平台将会做什么」 | 唯一允许查库 |
ManifestScoper | 决定哪几项给人看 | 不填 ChangeItem |
UntrustedInputTracer | 另一张正交表 untrustedInputs[] | 「改的时候喂进去的值有多少是别人能写的」 |
忘配抽取器不会静默少一块——退回 GenericToolChangeExtractor,并在 stats.genericTools 上留下计数。ChangeKind.GENERIC_TOOL 的注释逐字:「这一档存在的唯一理由是『新增写工具时忘了配抽取器』不能表现为清单里少一项」。
只读工具不进清单,但要计数
WorkflowChangeManifestBuilder:66-69:「stats.readToolCount 存在的理由是『证明扫过了』:清单说『本次不产生任何写操作』时,用户需要能区分『真的没有写操作』与『有 5 个工具但清单没认出来』。」
这条与上面 GENERIC_TOOL 那条是同一个动机的两种落法。
六步顺序不可换(:122-127,逐字):
* <p>🔴 顺序不能换:<b>遍历 → 算 downstreamKinds → 干跑 → 溯源 → 裁剪 → 汇总</b>。
* 干跑要在裁剪之前(理由见 {@link ManifestScoper} 类注释:跨项的「本流程自己会建出哪些表」
* 需要看到全图,否则最标准的 {@code 建表 → 审批 → 建表单} 会被冤枉成「主表不存在」);
* 溯源同样要在裁剪之前(裁剪器要按 {@code sinkNodeId} 剪它并记账);
* 汇总要在裁剪之后({@link #finish} 从<b>留下来的</b> {@code changes} 派生 stats 与 headline,
* 于是「裁了但没重算」这种不一致在结构上就写不出来)。溯源那一步刻意不包 try/catch(:210-213):
// 🔴 刻意不包 try/catch:兜住就等于把一次崩溃翻译成「没有外部输入流进任何入参」,
// 比整份清单生成失败更坏 —— 前者用户看得见,后者用户点了同意。
// 与干跑的 fail-soft 不矛盾:那边兜的是「infra 不通」这类预期内的外部故障,
// 这边能兜到的只可能是本模块的 bug,而 bug 不该被翻译成一句让人放心的话。干跑是构建的一部分,不是可选项(:200-202):
// 🔴 干跑是构建的一部分,不是调用方的可选项。
// 做成「想要就再调一次 dryRunner.enrich」的话,忘调的调用方会拿到一份
// dryRun 全空的清单 —— 而全空与「全部通过」在界面上长得一样。子流程节点必须显式说出来(:179-186):「🔴 但必须显式说出来:子流程里建一张表,与顶层建一张表对用户是同一件事,静默跳过就等于清单在『将会做什么』上撒谎。」于是它落进 unresolved[],文案是「其内部的变更未展开;如需确认全部影响面,请单独打开该子流程查看它的变更清单」。
工具解析顺序必须与执行路径逐字同口径(:58-63):registry-first,与 AgentToolExecutor / AgentSecurityGateway / 名称目录一致。「若顺序不同,清单上显示的是自定义工具的展示名与风险、真正执行的却是同名内置工具——这类分叉从界面上完全看不出来,而它恰好发生在『用户据此点同意』的那一屏。」
四、干跑:十种变更,五种探得了
ChangeManifestDryRunner 的类注释(:62-78)自己给了这张表:
ChangeKind | 干跑覆盖到什么程度 |
|---|---|
TABLE_CREATE / TABLE_ALTER | ✅ 与真执行同一条链,外加一次表名撞车探测 |
FORM_CREATE | ⚠️ 只探得到「主表存不存在」「编码撞没撞」两个查询;探通了也不报绿 |
FORM_PUBLISH | ✅ 体检 + 菜单预演,publish 的两条拒绝路径都覆盖 |
PROCESS_DEPLOY | ⚠️ 四项都探;但绑普通表单时不报绿,节点上配了独立表单时也不报绿 |
GENERIC_TOOL / DATA_INTERFACE | ❌ 原理上探不了——改什么取决于运行期参数与被调用方自己 |
TABLE_REGISTER / FORM_UPDATE / DATA_WRITE | ❌ 落 default 分支,明说「这类变更还没有干跑接口」 |
三态在 DryRunResult.Outcome:PASSED / REJECTED / NOT_PROBED。
NOT_PROBED 既不是红也不是绿
ManifestDryRunTag.vue:6-9 逐字:
🔴 三态不能塌成两态,这是清单四条硬纪律里的第二条(方案 §L1147):
NOT_PROBED 是「本次没校验」,**不是**「不通过」——把它渲染成红色,
用户会去改一份本来没问题的 DSL;渲染成绿色更糟,等于替 infra 的故障背书。后端同一条纪律在 ChangeManifestDryRunner:88-90:「非 ServiceException 一律落 NOT_PROBED(黄色、『本次没校验』),绝不落 PASSED。意图保住了,且清单不会因为 infra 抖动整个消失。」
耗时预算只实现了一半(:92-100,逐字):
* <p>🔴 本类只实现了<b>整体</b>那一条({@link #BUDGET_SECONDS},逐项检查剩余时间,
* 超了就给剩下的项落 {@code NOT_PROBED})。<b>单次那一条实现不了</b>:这些是<b>同进程</b>的
* api 调用,要打断它得另起线程池 + {@code Future.get(timeout)},而 JDBC 调用被打断后
* 底层连接往往照旧跑完,等于白花一个线程。单次上限实际由数据源自己的 socket timeout 兜。BUDGET_SECONDS = 15(:111),逐项检查剩余时间(:186-189),超了就给剩下的项落 notProbed("干跑总预算(15 秒)已用尽,本项未探测")。
已知残留:建表链有第三份副本(:54-60)。previewDdl 的新建分支与 DataModelCreateValidator#validateTableName 都不查表名是否已被占用,只有 DataModelServiceImpl#createDataModel 在执行时才拦。于是干跑另外调 getPhysicalTableList 补一次存在性探测,结果落在 ChangeItem#getConflict() 而不是 DryRunResult——「它不是『参数写错了』,是『撞上了已有的东西』,出路也不同(换名 / 导入)」。注释明说「这条链于是有了第三份副本,漂移面是『步骤清单』本身,用 ChangeManifestDryRunnerTest 的调用顺序断言钉住」。
五、V 号段:权威在哪、怎么数
这一节是本页最容易踩的坑,请完整读完再改校验器
前端 PublishGateDialog.vue 里写过一句「号段与生效条数的权威只有一份,在后端 WorkflowDslValidator 的类 javadoc 里」。这句话是错的:类 javadoc(:65-161)里没有任何号表,它只在散文里提到几个号。
真正的权威在 WorkflowDslValidator:5349-5441——埋在 validateChatLegsWriteReply(也就是 V20)自己的 javadoc 里。一个 5800 行的文件,号段纪律藏在第 5349 行、某条规则的注释里。
🔴 这个位置本身也会漂:本页上一版记的是 :4903-4993(当时文件 5341 行)。文件每长一截,权威段就往下挪。要引用它请按内容定位——搜「顺带钉一个实测口径(缺陷 D4-07)」那句,别抄行号。
权威口径(:5349-5358,逐字关键句):
🔴 顺带钉一个实测口径(缺陷 D4-07):本文件的校验规则号是 V1~V16、V18~V35 —— 共 34 个号,唯一缺的是 V17。
🔴 下一个可用号 = V36,且只在本段说这一次(
:5361)。
V17 从来不是一条规则:它「结构上不可触发」这件事记在 ChangeManifest#untrustedInputs 与 UntrustedInputTracer 的类注释里,校验器这边压根没落地过。
⚠️ 号域是活的:本页上一版钉的是「V1~V16、V18~V32 共 31 个号、下一个可用 V33」,V33 / V34 / V35 是之后补的(规则头分别在 :4961、:5117、:1552)。引用号数前先自己重数一遍,方法就在下面。
怎么数(2026-10-25 已按这个方法复算过一遍)
权威给的方法(:5399-5406)是:数 section 分隔条(// ==== …(V..)====)与各规则 javadoc 标题行里的「本条是 Vxx」。
本页实测:
grep -c '本条是 <b>V' WorkflowDslValidator.java= 19,其中 4 处是元叙述(:2005、:5372、:5400、:5401),15 处是真规则头——正好是 V21~V35。- section 分隔条共 22 条,给出 30 个不同的号:
V1/V2/V3、V4/V5/V12、V7/V8/V10、V11/V13/V14、V16、V18、V19、V20、V21~V27、V29~V35。 - 另有 4 个号没有自己的分隔条:
V6(:2108、:2216)、V9(:2837)、V15(:375、:384)、V28(:3263、:391)。 - 30 + 4 = 34 ✅ 与权威钉的数字对上了。
禁用全文 grep 'V[0-9]'
权威在 :5402-5406 明确禁掉了这个最自然的做法:
⚠️ 不要拿全文
grep 'V[0-9]'去数:规则号在本文件里只存在于注释中(没有一处写在字符串字面量或常量名里,2026-10-04 核过),而本段注释自己就写着若干个号(V17、V26,以及 R-09 ⑥ 那两节里被点名的 V19、V20~V25 —— 🔴 这个列举本身也会随补充而漏,所以别拿它当清单)—— 全文 grep 必然把它们一并数进去,「缺 V17」这条判据会被它自己的说明文字证伪。
本页写作时先犯了这个错:全文 grep 数出「33 个不同的号,V1..V33 无缺口」。那 33 = 31 个真号 + V17(散文里说它不是规则)+ V33(散文里说它是下一个可用号)。这条禁令是有效的,而且它精确预言了错误会长成什么样。(今天再犯一次的话,数出来的会是「V1..V36 无缺口」——V36 正是散文里那个「下一个可用号」。)
落地新规则要改几处
权威在 :5362-5368 定了口径,本页照抄:
- ① 本段的号域与「下一个可用号」;
- ② 新规则自己 javadoc 里的「本条是 Vxx」(section 分隔条上有号的一并改)。
- 不要在新规则的 javadoc 里再抄一遍「下一个从 Vxx 起」。
原先有 4 份「再加新规则从 Vxx 起」的副本,每落地一条就要同时改 4 处、漏一处就留下一句假话——「而这类假话不会让任何测试变红(它是注释里的数字,CommentLineRefBanTest 管的是行号不是号数)」。迭代七第 05 轮加 V23 时已把三份副本删成「见本段」。
还有第三种漏法(:5370-5375):新规则压根不取号。validatePublishParentMenu 落地时 section 分隔条与规则头都没写号,于是它对 grep -c "本条是 <b>V" 这个判据完全不可见——号域看着仍是 25 个、grep 也仍与它自洽。是补 V28 时才发现、把 V27 回填给它的。所以 ② 那一处不是「可选的文档美化」,它是这条规则存在于号域里的唯一凭证。
V20 撞号
:4884-4901 记了一件从未被发现过的事:迭代二「明确不做」里那条 V20 是「iteration 循环体内含写工具且无幂等键 → warning」,且当时就明写正确落点不是 V20,而是给 NodeIoContract 增一个 idempotencyKey 声明位。迭代三独立落地了另一个 V20(会话腿没人写回复键),「落地时压根不知道那个号被预留过——所以这个撞号从来没被发现过」。
处置口径:本条不改名;幂等键规则若日后要做,编号取「本段钉的下一个可用号」;🔴 别与既有字段接错——AgentNodeExecutionDO 已经有一个 idempotencyKey,那是重放去重用的,与声明位不是一回事。
曾经有第三份规则全集,已删
:4930-4945 记着一个更坏一档的同类缺陷:script/verify-workflow-orchestration/verify_dsl_gate.py 里的 LIVE_RULES。它是 2026-10-07 才补进来的,补的原因是已经连漏三轮——V20、V21、V22 落地时各自都只补了单测,没人动那张表,而覆盖台账的宇宙就是那张表。于是那个脚本一路打印「双向都量到 15 条」+退出码 0,而实际少算 3 条生效规则。
这与上面那三份副本是同一类缺陷(漏改一处 ⟹ 留下一句不会变红的假话),但更坏一档:副本漏改留下的是注释里的错数字,那张表漏改留下的是一个恒绿的验证脚本,而脚本的全部意义就是「让人不必自己判断闸门好不好」。
2026-10-09 核实:那个脚本连同整个目录已在「清理垃圾文件」那次提交里删除,本仓当下没有第二份规则全集。但教训一个字都别删——「若日后再出现任何一份规则全集(脚本、台账、前端夹具表),就必须把它加回这个 ①②」。
前后端口径已经治过,后端还没治
R-09 ⑥(2026-10-06)的裁定是:前端凡是说 V 系列规模的地方一律改成指向那段 javadoc,不再写数字;单条规则号照旧保留。复核判据是
grep -rn "19 条\|V1~V19\|编号到 19\|17 条生效" src/降到 0,不是「变少」。
本页实测这条判据:
| 仓库 | 命中 | 状态 |
|---|---|---|
ainb-ui-admin-vue3/src/ | 0 | ✅ 裁定已执行完 |
ainb/(全部 .java,含校验器自身元叙述) | 17 | — |
ainb/(排除 WorkflowDslValidator.java) | 12 处 / 6 个文件 | 🔴 未治 |
那 6 个文件是 AgentWorkflowPlanReqVO(:28)、AgentWorkflowDefController(:276)、AgentWorkflowDefAuditRespVO(:13)、ErrorCodeConstants(2 处::350、:363)、AgentWorkflowDefService(2 处::106、:412)、AgentWorkflowDefServiceImpl(5 处::309、:334、:726,含 assertPublishable 自己的 javadoc 标题行 :845 与硬约束第 1 条 :856)。1 + 1 + 1 + 2 + 2 + 5 = 12 ✅。
🔴 本页上一版这两格都错:全量写的是 21(实测 17),AgentWorkflowDefServiceImpl 写的是 4 处(实测 5)——后者让那一行自己加不成 12,是算错而不是数错。多出来的 5 行全在 WorkflowDslValidator.java 自己身上(:5353、:5412、:5424、:5425、:5428),是号段权威那段在点名这个过期口径、而不是在用它——与第五节「禁用全文 grep 'V[0-9]'」是同一种自指陷阱,所以「排除校验器自身」这一步不是可选的清理,它是这条判据的定义。
这不是「没人发现」,是「判据的范围恰好是仓库边界」
那条 grep 写的是 src/——前端的路径。后端那 12 处从来没进过它的射程。
而且校验器在 :5353 已经预先处置了这件事,只是处置方式是给读者一条阅读规则而不是改代码:
所以别处文档写的「V1~V19」「19 条校验」一律按「号有 34 个,其中 V17 结构上不可触发」来读
⟹ 后端那 12 处属于「已知且已被裁定为可读」,不属于「隐藏缺陷」。但对本手册的读者来说结论是一样的:看到「V1~V19」请按 34 个号读。
六、HITL:挂起发生在节点之前
HitlConfirmNodeExecutor 只有 71 行,但它的类注释(:15-22)把机制说全了:
* <h3>🔴 挂起发生在节点<b>之前</b>,本执行器只在恢复后跑</h3>
* 编译器对每个 hitl.* 节点调 {@code interruptBefore(id)}:SAA 在执行到该节点前挂起、
* checkpoint 落库、stream 正常结束(末元素是 InterruptionMetadata)。
* 前端确认 → {@code updateState(config, {approveVar: true})} → {@code withResume()} →
* {@code stream(null, config)},此时本执行器才被调用,读到的 approveVar 就是用户的决定。
*
* <p>所以这里<b>不做任何等待/轮询</b> —— 等待是 checkpoint 挂起天然实现的(可以等几天,
* 跨进程跨重启都在,SPIKE 验证①②)。执行器只负责「把决定读出来、写个确认标记」。DEFAULT_APPROVE_VAR 必须被无条件注册
HitlConfirmNodeExecutor:36-43 逐字:
/**
* 默认确认变量名,也是<b>聊天内恢复</b>唯一会写的键({@code AgentAssistantServiceImpl.HITL_APPROVE_VAR})
*
* <p>{@code KeyStrategyFactoryGenerator} 无条件注册这个键:SAA resume 的 {@code OverAllState.input()}
* 只保留已注册 KeyStrategy 的键,不注册的话聊天续跑喂进来的决定会在恢复瞬间被丢掉
* —— 表现为「点了同意却按拒绝走完,状态还报成功」。
*/
public static final String DEFAULT_APPROVE_VAR = "hitl_approved";失效方向:静默。用户点了同意,流程按拒绝的那条腿走完,状态还报 SUCCEEDED。这是本页记的所有坑里最坏的一个——它同时骗过用户和执行史。
自定义 approveVar 的回退(:52-58):
String approveVar = node.configStr("approveVar", DEFAULT_APPROVE_VAR);
// 聊天内恢复(AgentAssistantServiceImpl)只会写死键 hitl_approved,不认 DSL 里的自定义 approveVar。
// 不回退就会读到 null → 判成拒绝 → 流程静默走 default 分支还报 SUCCEEDED,用户以为「同意了却没干活」。
Object approved = ctx.get(approveVar);
if (approved == null && !DEFAULT_APPROVE_VAR.equals(approveVar)) {
approved = ctx.get(DEFAULT_APPROVE_VAR);
}七、BPM 重审批:清单冻结进流程变量
hitl.bpm_approval 走的是另一条路——发起一条真的 BPM 流程实例。AgentBpmApprovalService 的类注释(:37-57)记着四个决定:
1. businessKey = 唤醒凭据。 发起时把 businessKey 设成 agent-wf:executionId:nodeId,并同时落成执行实例的 event_key。审批完成时 AgentBpmApprovalListener 收到 BpmProcessInstanceStatusEvent,用事件回带的 businessKey 走既有 publishEvent(eventKey,...) 广播唤醒——「零新查询、零 executeIgnore,全程复用 wait(event) 已验证的 resume 链路与 CAS 去重」。
2. 载体是对象树,不是 JSON 字符串。 这条有实测数字(:46-50):
* <b>载体是对象不是 JSON 字符串</b>:{@code act_ru_variable.TEXT_} 是 varchar(4000),而实测 8 条最大存量流程
* manifest 6 条超限(最大 8477 字 = 212%)⟹ 字符串载体必然炸。Flowable 对非基本类型走 {@code serializable}
* → {@code act_ge_bytearray} blob,无此上限。故用 {@link #toManifestTree} 把 {@code ChangeManifest} 转成
* JDK 类型树(LinkedHashMap/List/String/Number),<b>绝不直接塞 POJO</b>(Java 序列化会把类名与
* serialVersionUID 冻进历史实例,改字段后旧实例反序列化失败)。3. 绝不原地 put(:51-52):formVars 可能是 DSL 里那个活 map,注入前先拷一份。
4. 构建失败注入降级清单,不是不注入(:55-56):「『没算出来』和『不改什么』在审批页上必须是两句话。」方法注释(:102-104)把这条钉得更死:manifestTree == null 只应由 injectManifest=false 产生(作者主动关掉,审批页不出现页签是对的);清单构建失败时注入的是 ChangeManifest#degraded(String),「否则审批人分不清『这流程不改什么』和『平台没算出来』,等于让他盲批」。
这解释了 ChangeManifest.degraded 为什么是一个显式布尔而不是靠「changes 是空的」推断(:44-47):
* <p>🔴 为什么不能靠「{@code changes} 是空的」来表达:空清单有两种完全相反的读法——
* 「扫过了,确实不写」和「压根没扫成」。前者是清单可信的全部依据,后者是审批人在盲批。
* 渲染层必须能分辨,所以这是一个显式布尔,不是一个待推断的空集合八、发布闸门(S4b)
AgentWorkflowDefServiceImpl.assertPublishable(:882-896)只有 15 行,但它的方法注释(:844-881)是这一页最该读的一段。
与 auditAll 逐字同形(:847-851):同一份 dsl、同一个 buildManifestOrNull(dsl, null, null)、同一个 result.isPassed() 判据。「这样『盘点报告里 failed 的那一行』=『发布被拒的那一行』是结构保证,不是巧合——报告是给人做开闸决定用的,只要两处判据有第二份副本,它就迟早与闸门分岔,于是『报告说 0 行不通过、闸门却拒了 6 行』,而那份报告已经被人当成放行依据了。」
三条硬约束(:853-862):
- 不提供 force / skipValidation 入参,也没有 yaml 降级开关。「任何一个『二次确认强推』的按钮都会把 V 系列从闸门降格成建议。」
- 只挂在两条发布路径上(
publishDef(String)/publishDefById(Long)),不挂 createDef / updateDef——草稿允许半成品,落草稿就拦会阻断正常编辑顺序。 - 幂等短路在闸门之前:已 PUBLISHED 的行直接返回,不再体检。「否则『V 系列后续加严』会让一次重试把历史上合法发布过的行报成不通过,而它并没有被改动。」
刻意不包 try/catch,这与 auditAll 不同(:864-868):
* 盘点那边把异常记成 {@code auditFailed} 并继续下一行,因为「体检工具在这一行坏了」与「这一行违规」
* 是两件事,且一行坏 DSL 不该让其余几十行的报告全丢。
* 闸门这边没有下一行要救,而异常冒泡 ⟹ 事务中止 ⟹ 状态没翻 ⟹ <b>已经是 fail-close 了</b>。
* 再包一层只会把「体检自己坏了」伪装成「你的 DSL 不通过」,让人去 DSL 里找一个根本不存在的问题。执行史按 code 展开(:876-880):传给 doValidateDsl 的次数是 countExecutionsByCode,即「本 code 名下全部版本」的合计。「回滚会造出同 code 的新 id,那个新版本自己一次都没跑过,执行史全在旧 id 上——少展开这一步,『回滚出的草稿』就会被判成『从没跑过』而照样拦下。」
抛出的文案用「;」而不是「\n」拼接(:890-894),因为「这条最终落到 ElNotification 的 title,换行在 title 里不渲染」。
干跑层默认带上(:870-874):实测 64 行全量盘点 0.6 秒 ⟹ 同步扛得住。buildManifestOrNull 建不出来时返回 null,此时 V4/V5/V12 静默缺席而其余照常判——「即『infra/bpm 当时不通』不会把发布堵死(探针失败不是违规),但也绝不会因此放过静态层的问题」。
九、前端预检:为什么明明有后端闸门还要再体一遍
AssistantChat.vue:1743-1749 逐字:
// 🔴 发布前先自己体一遍(S4b)。C2 之后后端那句拒绝文案**能到这里了**(下面 catch 就把它拼了进去),
// 但预检依旧不是「少一次往返」的优化:那句话是七八条修正项用「;」拼成的一长串,
// 拦截器只会把它塞进一个 4.5 秒自动消失的通知标题,用户读不完它就没了,
// 而这条路径上的草稿恰恰是 LLM 一次生成的、最可能不过体检的那种。
// 这个预检因此是这条路径唯一能把「改哪儿」**逐条、且停在那儿**说给人听的通道。
// ⚠️ 但 /validate 要 agent:workflow:query,那是这条路径今天没有的一个权限要求,
// 403 时按 publish-blind 降级照发(见 publishGate.ts),修 #70 时两个权限要一起给。PublishGateDialog.vue:2-10 记着同一件事的另一半——为什么它必须是对话框不能是 toast:
🔴 为什么必须是一个对话框、不能是一条 toast:后端那条 WORKFLOW_DEF_VALIDATE_FAILED 的文案里
逐条写了「怎么改」,但 axios 拦截器只把它塞进一个 4.5 秒自动消失的通知标题。
一份可能有七八条、每条几十字的修正清单,用户读不完它就没了,只能反复点发布反复看它闪。
对话框让它停在那儿,人可以照着一条一条改。
(台账 C2 已修的是另一半:「调用方 catch 里拿不到 msg」。通知本身的 4.5 秒一点没变,
所以这个对话框的理由一条都没少 —— 别看到 C2 已修就以为它可以降格成 toast。)
🔴 全部用文本插值渲染,不用 v-html:errors 是后端按 DSL 内容拼的,里面会有用户自己写的
表名 / 节点名 / 字段名。那些是数据不是标记,v-html 等于把一条 XSS 通路接到发布流程上。publishGate.ts:81-84 的判据是 fail-close 方向:
// 🔴 判据取「passed 为假 **或** errors 非空」,而不是只信其中一个:
// 后端的 passed 就是 errors.isEmpty(),两者本该等价;一旦不等价(字段漂了、响应被截断了),
// fail-close 的方向是拦下来 —— 一道闸门在自相矛盾时放行,比它误拦一次坏得多。
if (!result.passed || errors.length > 0) {三态是 publish / block / publish-blind。publish-blind 是「预检自己没体成,照发,让后端闸门做唯一真判据」——它不是绕过闸门。
十、Saga 逆序补偿
AgentWorkflowCompensator 的类注释(:24-52)先把边界划清:「工作流失败时,已经跑成功的副作用节点(插了记录、发了 HTTP)不会自动撤销——本平台不承诺 ACID 回滚。只有显式声明 recovery: compensate 的流程,失败后才由本调度器按 agent_node_execution 的逆序逐个调用 compensate_ref 指向的工具做『语义反做』。这是尽力而为的撤销,不是事务 rollback。」
三条不可动摇的口径:
| 口径 | 理由 | |
|---|---|---|
| 1 | 逆序(selectCompensableDesc,id DESC) | 正序撤会出现「先删 A 才发现 B 依赖 A」的悬空 |
| 2 | 只撤跑成功的(status=SUCCESS 且 compensate_ref 非空) | 「宁可漏撤一个未完成的,不误撤一个没发生的」 |
| 3 | 尽力而为、不中途放弃 | 单个补偿失败只记录、继续撤后面的;最终只要有一个失败就落 COMPENSATION_FAILED(= 有写操作生效又没撤干净,必须人工核对) |
幂等(:44-47):触发点 settle() 有 settle-once CAS 保证「一次执行只补偿一次」;补偿动作本身可能因工具重放而重复,故把 idempotency_key 作为保留参数 __idempotencyKey 透传给工具,由工具侧去重。「本调度器不自建去重表——那超出 MVP,且删除按 id 本就幂等。」
身份与线程(:49-51):全程包在 context.execute 里贴回租户身份;工具调用再经 AgentToolExecutor 的六步权限闸门——「补偿走的仍是『唯一工具入口』,不绕权限」。
数据模型
ChangeManifest 永不存库——唯一例外是 BPM 冻结
清单是一份诊断物,每次要就重算。全仓唯一被持久化的形态是 hitl.bpm_approval 挂起时冻结进 Flowable 流程变量 agent_change_manifest(载体是 JDK 对象树,落 act_ge_bytearray blob)。
ChangeManifest.VERSION = 1 是给前端认载荷用的(「加字段不动它,改字段含义才动」),不是数据库schema 版本。
与本页相关的表(DDL 行号取自 sql/mysql/ainb-init.sql):
| 表 | DDL 起始行 | 与本页的关系 |
|---|---|---|
agent_planner_adoption | 6087 | 采纳率埋点,全仓唯一冻结 Plan 副本的地方 |
agent_workflow_def | 6763 | 草稿与发布态、code / version / primary_flag |
agent_workflow_execution | 7024 | status(含 INTERRUPTED / COMPENSATION_FAILED)、event_key、resume_at |
agent_node_execution | 5743 | 节点级 trace,补偿按它逆序取数;含 compensate_ref / idempotency_key |
agent_message | 5682 | 聊天侧的草稿卡与 HITL 卡落在这里 |
agent_planner_adoption 的关键列(:6087-6097):
`workflow_code` varchar(64) NOT NULL COMMENT 'AI 生成的流程编码',
`workflow_def_id` bigint NULL COMMENT '生成时落的草稿定义编号',
`baseline_dsl` mediumtext NULL COMMENT 'AI 生成时的 DSL 基线(确认时比对用)',
`adopted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否已确认并运行(发布)',
`hand_modified` bit(1) NULL COMMENT '确认时是否手改(NULL=尚未确认)',
`edit_count` int NULL COMMENT '手改处数(规范化 JSON 行差异数;NULL=尚未确认)',baseline_dsl 只为指标服务
它是全仓唯一被冻结的 Plan 副本,而它的用途不是「确认时校验用户看到的就是这份」——recordAdopted 拿它比对的产物是 hand_modified 与 edit_count 两个埋点字段,喂给 /planner-adoption-stat。
也就是说:冻结基线这件事存在,但它不承担「防漂移」的职责。防漂移那一格是空的(见上面「三道防线之间没有令牌」)。
接口清单
规划与草稿(/agent/planner)
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| POST | /plan-workflow | agent:assistant:use | 把一句需求拆解成 DSL 草稿,落库并(可选)发布 |
| POST | /publish-workflow | agent:assistant:use | 确认并发布草稿(DRAFT → PUBLISHED,幂等) |
| GET | /workflow-draft | agent:assistant:use | 取草稿 DSL + 变更清单(确认卡渲染的唯一数据源) |
publish-workflow 的幂等语义写在 :132-135:「最新版是 DRAFT 就翻成 PUBLISHED,已是 PUBLISHED 直接返回其 id。刻意不复用 hitl 的挂起/恢复——那套 suspend-resume 绑定『一个活的 workflow 执行 + DB checkpoint 线程』,与聊天里一张轻确认卡不匹配;发布完由前端另起一次 run-stream 执行,更简单、风险更低。」
发布之后紧接着钉主版本(:147-151),理由逐字:
// 🔴 发布即生效(B6 / S16):本端点是聊天确认卡「确认并运行」的第一步,前端紧接着就按
// **同一个 code** 起 run-stream(不带版本号)。不钉主版本的话,若该 code 之前被钉过
// 主版本(灰度回退场景),这一跑会落到上一版 —— 用户确认了 A、系统跑了 B。
// 与管理页发布(publishDefById 只在无主版本时补钉)刻意不对称,理由见上一条注释。体检与清单(/agent/workflow-def)
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| POST | /validate | agent:workflow:query | 预检,返回 errors/warnings + 审核后的清单副本 |
| POST | /manifest | agent:workflow:query | 单独生成清单(走严格版,建不出来必须报错) |
| GET | /audit-all | agent:workflow:query | 盘点当前租户全部定义(开闸前的存量体检) |
| POST | /publish | agent:workflow:publish | 按 id 精确发布该版本(挂闸门) |
| GET | /node-io-contract | agent:workflow:query | 节点输入/输出端口契约 |
| GET | /planner-adoption-stat | agent:workflow:query | 规划采纳率统计 |
/manifest 与 /validate 的差别(:231-233 与 :261-263):
// 🔴 用 OrNull 版本:清单在这里是「附带品」,它生成不出来不该把用户真正要的体检结果一起带走。
ChangeManifest manifest = defService.buildManifestOrNull(reqVO.getDsl(), null, null);// 🔴 走严格版:这个端点的产品就是清单本身,生成不出来必须报错。
// 若在这里也兜成 null,前端拿到 200 + null,与「这流程不改任何东西」在界面上是同一句话 ——
// 而那句会让人放心地点同意。这正是 S2 全刀反复钉的那条:静默少一块比报错坏。审核点在控制器,不在 builder
/manifest 的 :265-266:「【B2-2】审核点在这里而不在 buildManifest 里:这个端点的产物就是给人看的,而 buildManifest 的产物还要喂发布闸门——在源头审核等于把闸门喂成被改过字的清单。」
/validate 的 :248-252 把这条讲得更细:交给前端的是审核后的副本,而喂 validateDsl 的是原始那份,「这两者不能合并成一个变量:审核会剥不可见字符、加前缀、超 2000 截断;而折叠器对 sinkKind 做的是字符串 equals、对 sinkNodeId 做的是 map 取值——文字动过一个字符就静默匹配不上、continue 放行,发布闸门从『拦』变成『放』」。
运行与恢复(/agent/workflow)
| 方法 | 路径 | 权限 | 说明 |
|---|---|---|---|
| POST | /run-stream | agent:workflow:run | 启动执行(SSE) |
| POST | /resume-stream | agent:workflow:run | hitl 确认后恢复(SSE) |
| POST | /event | agent:workflow:run | 发布外部事件唤醒 wait(event) |
| POST | /cancel | agent:workflow:cancel | 中止(落 CANCELLED,不反做、不告警) |
resume 的原子认领(AgentWorkflowExecutionServiceImpl:190-205):
AgentWorkflowDefDO def = defService.getDef(exec.getWorkflowDefId());
// 🔴 B3-3:定义状态检查。刻意放在 CAS 认领<b>之前</b> —— 认领会把 INTERRUPTED 改成 RUNNING,
// 先认领再拒就会在库里留一行「状态说在跑、其实没人跑」的僵尸。放在这里,行原样停在 INTERRUPTED,
// 与事实一致(它确实还挂在 checkpoint 上),管理员要清场走抽屉里的「取消」。
requireDefResumable(def, exec);
WorkflowDefDTO dto = dslParser.parse(def.getDsl());
Map<String, Object> inputs = feedback == null ? new HashMap<>() : new HashMap<>(feedback);
// 🔴 增量4a:原子认领。把「setStatus(RUNNING)+updateById」换成条件更新
// UPDATE ... SET status=RUNNING, resume_at=NULL WHERE id=? AND status=INTERRUPTED。
// 轮询器(WorkflowWaitResumeScheduler)到点自动 resume 与人工 resume 可能并发撞同一条执行,
// 谁先把 INTERRUPTED CAS 成 RUNNING 谁赢,另一个影响 0 行→抛 STATE_INVALID 放弃,杜绝同一执行被双跑。
if (!executionMapper.casInterruptedToRunning(executionId)) {
throw exception(WORKFLOW_EXECUTION_STATE_INVALID, exec.getStatus());
}怎么扩展
加一种变更类型(ChangeKind)
- 在
ChangeKind里加常量,必须同时给label与headlineTemplate——模板放进构造参数就是为了让「漏文案」编译不过(类注释:9-14:「若把它写成 builder 里的一个switch(kind),新增一种变更类型时编译器不会提醒补分支,结果是『新类型的变更做了、但那句话里没提』」)。 - 🔴
headlineTemplate不要以「将」开头——builder 把各片段用「、」连起来后统一前置一个「将」,模板里再写一遍就成了「将将新建 1 张数据表」。 - 量词写在模板里(「1 张表」而不是「1 个表」):中文量词按名词走,交给 builder 统一拼会退化成「1 个 XXX」。
- 写一个
ToolChangeExtractorbean。忘了写不会静默少一块(退回GENERIC_TOOL+ 计数),但清单会说不出细节。 - 如果这类变更可以干跑,去
ChangeManifestDryRunner加一支;如果原理上探不了,让它落default分支——那里会明说「这类变更还没有干跑接口」,比留空诚实。
加一条 V 规则
🔴 必须同步改两处(权威口径见上面第五节):
WorkflowDslValidator:5349-5361那段的号域与**「下一个可用号」**(定位靠内容不靠行号,见第五节);- 新规则自己 javadoc 里的**「本条是 Vxx」**(section 分隔条上有号的一并改)。
不要在新规则的 javadoc 里再抄一遍「下一个从 Vxx 起」——原先就是这么攒出 4 份副本的。
新规则压根不取号是第三种漏法,它对 grep -c "本条是 <b>V" 这个判据完全不可见。
判 error 还是 warning,看 AgentValidateResult 里 warnings 那一档的定义:「后端不报错、保存成功、但跑出来的不是你要的」。V20 的注释(:5293-5296)给了取舍理由:「判 error 会阻断发布,而『某条腿不说话』到底算不算缺陷取决于业务(静默丢弃的分支是存在的),平台不该替作者裁定;与 V11 那条教训同向——规则必须先承认哪些写法是合法的,才可满足。」
warning 不会回灌给模型
:5298-5303:WorkflowPlannerServiceImpl#planToWorkflow 只在 !result.isPassed()(即有 error)时才调 toFeedback()。所以一条 warning 单靠自己改不了编排器的产物,它只让人在草稿卡与体检面板上看得见。让模型别再产这种图的那一半在提示词侧——「而按方案 §2-9,『模型是否采纳提示』这一环验不了,所以两侧都得有,谁也不替谁」。
加一种 hitl.* 节点
- 写
NodeExecutor实现,getType()返回hitl.xxx——WorkflowChangeManifestBuilder按HITL_PREFIX = "hitl."前缀收进approvals[],与DslToStateGraphCompiler登记interruptBefore的判定同源。 - 🔴 去
NodeIoContract登记端口契约。WorkflowDslPlannerPrompt.NODE_CATALOG的注释(:196-201)记着漏登记的后果:「本段曾漏掉script与hitl.bpm_approval两种,于是模型压根不知道有这两种节点:需要无头流程的人工闸口时它只会写hitl.confirm——而后者在无头触发下被 V13 判 error。」 - 同步改
WorkflowDslPlannerPrompt的节点目录(强制点在WorkflowDslPlannerPromptTest,它会把 few-shot 样例真解析一遍、逐个 config 键回查契约)。 - 如果它需要一个新的确认变量,去
KeyStrategyFactoryGenerator无条件注册——见上面第六节那条 danger。
坑与注意
失效方向为「静默 / fail-open」的(13 条)——这些不会报错,只会让人做出错的决定:
- 🔴
DEFAULT_APPROVE_VAR未注册 ⟹ 点了同意却按拒绝走完,状态还报成功。HitlConfirmNodeExecutor:39-41。本页最坏的一条。 - 🔴 script 节点里的写操作对变更清单完全不可见。 沙箱里只有
state、没有工具句柄,tools.form_save_draft(...)一定抛ReferenceError;而清单只从 tool 节点提取。提示词逐字(WorkflowDslPlannerPrompt:222-227):「用户会在『一条变更都没有』的卡片上点确认。」 - 🔴 子流程内部的变更不展开,只落一条
unresolved[]。清单不会假装看不见,但审批人得自己去打开那条子流程。 - 🔴 三道防线之间没有令牌:用户看到的清单与真跑的那份 DSL 之间只隔着「两次请求之间没人改库」这个假设。
- 🔴 草稿卡「确认并运行」无 in-flight 保护。
AssistantChat.vue:1741-1787唯一的守卫是if (!item.workflowCode) return;item.running = true在runWorkflow内部才置位,而它前面有两个await(precheckPublish、publishWorkflow)。⟹ 双击起两条执行,而run-stream不幂等。 - ⚠️ 权限不对称:
/validate要agent:workflow:query,而「确认并运行」这条路径只有agent:assistant:use。403 时按publish-blind降级照发(AssistantChat.vue:1748-1749,台账 #70 待修)。降级方向是「让后端闸门做唯一真判据」,不是绕过闸门——但用户确实少了那份逐条清单。 - ⚠️
NOT_PROBED渲染成红或绿都是错的。 红 ⟹ 用户去改一份没问题的 DSL;绿 ⟹ 替 infra 的故障背书。 - ⚠️ 干跑「探通了也不报绿」的两处:
FORM_CREATE(字段与列对不对得上要先把简化描述展开成 form-create 结构,那是工具的私有实现,重写一份必然漂移)与PROCESS_DEPLOY绑普通表单/节点独立表单时。全查过了也只能落NOT_PROBED。 - ⚠️
el-collapse绑 boolean 会静默恒收起。WorkflowDraftCard.vue:308-314:非 accordion 的el-collapse用「当前展开项的 name 数组」当 v-model,绑一个false上去它跟任何name都不相等 ⟹ 恒收起,且一声不响,vue-tsc也不会报(那个 prop 的类型宽到能吞下 boolean)。 - ⚠️
data-testid必须挂el-tag不挂el-tooltip(ManifestDryRunTag.vue:11-14):el-tooltip不渲染自己的根元素,属性挂在它身上会被当成「非 props 的多余属性」丢掉 ⟹ e2e 找不到这个 testid,纪律②就没人守了。 - ⚠️ 后端仍有 12 处「V1~V19」(6 个文件),前端已治到 0。看到请按「34 个号、缺 V17」读。 🔴 数这 12 处不能用裸 grep:
grep -rn 'V1~V19' enlev-module-agent/src/main实测命中 17 行 / 7 文件, 多出的 5 行全在WorkflowDslValidator.java自己身上(:5353、:5412、:5424、:5425、:5428), 是号段权威那段在点名这个过期口径、而不是在用它们 —— 与第五节「禁用全文grep 'V[0-9]'」是同一种自指陷阱。 排掉那 5 行才是 12 处 / 6 文件(AgentWorkflowPlanReqVO、AgentWorkflowDefController、AgentWorkflowDefAuditRespVO、ErrorCodeConstants、AgentWorkflowDefService、AgentWorkflowDefServiceImpl)。 - ⚠️
OutputModerator:340那两个数都是错的。 它说「ChangeManifest有 8 个嵌套类、四十来个字段」——实测是 7 个嵌套类(WorkflowRef、ScopeInfo、ApprovalItem、WaitItem、UnresolvedItem、UntrustedInput、ManifestStats)、75 个字段(顶层 15 + 嵌套类内 60)。这与 V 号段那一整段警告的是同一类缺陷:注释里的数字,没有任何测试会为它变红。好消息是它不影响行为——moderateManifest走 JSON 往返而不是手抄,字段多一个少一个都不需要改代码,而「干净的清单必须原样往返」这条性质由OutputModeratorTest钉住。 - ⚠️
dsl字段刻意不审核(AgentWorkflowPlannerController:111-113):前端要JSON.parse它,而审核会把超 2000 字符的串截断,一份几十节点的 DSL 远超这个长度,截断等于把 JSON 从中间劈开。代价是「DSL 里的节点名(同样是 AI 写的)在预览里仍是原文」,作为残余面登记在总账 N33。
失效方向为「fail-closed / 会报错」的(6 条)——这些拦得住,但拦的方式值得知道:
- 🔴 发布闸门不提供 force / skipValidation,也没有 yaml 降级开关。 想发布只有一条路:把 DSL 改到真的通过。
- 🔴 规划超限 fail-close,绝不拿最后一轮的非法 DSL 凑合。
- 🔴
/manifest走严格版:建不出来直接报错,不兜成200 + null。 - 🔴 溯源刻意不包 try/catch:兜住就等于把一次崩溃翻译成「没有外部输入流进任何入参」。
- 🔴
resume的定义状态检查在 CAS 之前:先认领再拒会在库里留一行「状态说在跑、其实没人跑」的僵尸。 - 🔴 补偿失败落
COMPENSATION_FAILED——有写操作生效又没撤干净,必须人工核对,不会被自动重试掩盖。
术语口径:代码里叫 dryRun,界面上一律叫「预演」
ManifestDryRunTag.vue:3-5:「『预演』= 平台在发布/审批前把『照这份 DSL 真跑会发生什么』试算一遍,不落地。代码里的 dryRun 是机器名,界面上只说『预演』——『干跑』是 dry run 的直译,用户读不通,已于 2026-10-03 从全部用户可见文案里退场(注释与后端保留机器口径)。」
⟹ 写前端文案时用「预演」,写后端注释时用「干跑」,两边都对,别互相「统一」。
→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。
测试
| 测试 | 它钉的是什么 |
|---|---|
ChangeManifestDryRunnerTest | 调用顺序断言——建表链有了第三份副本,漂移面是「步骤清单」本身,只能靠顺序断言钉 |
WorkflowDslValidatorTest | V 系列逐条。要数用例数请 grep -c "@Test" WorkflowDslValidatorTest.java,别信任何注释里写的数字(:5436-5440 记着那个数从 145 漂到 182) |
WorkflowDslPlannerPromptTest | 把提示词里的 few-shot 样例真解析一遍,逐个 config 键回查 NodeIoContract |
AgentPlannerAdoptionServiceTest | 采纳率埋点的基线比对 |
这个模块跑不了 DB 测试
enlev-module-agent/src/test/resources 下只有 dsl-replay/(2 个 JSON 夹具),没有 application-unit-test.yaml。BaseDbUnitTest 依赖 @ActiveProfiles("unit-test") + @Sql("/sql/clean.sql"),两者都不在 ⟹ 上面这些测试全部是纯 Mockito。
这意味着「发布闸门真的拦住了吗」「CAS 认领真的原子吗」这类需要真库的问题,在本模块的单测里验不了。详见 架构总览 · 测试。
未证实
读源码读到的(本页逐字核过):
- 号段权威在
WorkflowDslValidator:5349-5441(文件现 5800 行;按内容定位,别抄行号),不在类 javadoc;口径 = V1~V16 + V18~V35 共 34 个号,V17 结构上不存在,下一个可用号 V36。 - 按权威给的方法复算:分隔条 22 条、覆盖 30 个不同的号,加上
V6/V9/V15/V28四个无分隔条的 = 34 ✅。 grep -c '本条是 <b>V'= 19,其中 4 处是元叙述、15 处是真规则头(V21~V35)。- 全文
grep -o 'V[0-9]\+' | sort -u数出 36 个、V1~V36 无缺口——这是权威明令禁用的方法,多出的两个正是 V17(散文里说它不是规则)与 V36(散文里说它是下一个可用号)。 - 「V 号只存在于注释里」本次复核仍成立:排除注释行后
grep '"[^"]*V[0-9]\+'命中 0,即没有一处写在字符串字面量里 ⟹ 号与方法不是 1:1,validateToolRefs一个方法就承载了 V1/V2/V3 三个号。实测private void validate*方法 33 个,别拿它当号数。 - 后端「V1~V19」12 处 / 6 文件(裸 grep 是 17 行 / 7 文件,多出的 5 行是号段权威段在点名这个过期口径);前端 0 处(R-09 ⑥ 判据已达标)。
manifest/= 27 文件 / 6178 行;core/plan/= 1 文件 / 1232 行。ChangeKind= 10 个常量;ChangeManifestDryRunner.BUDGET_SECONDS = 15。- 六步顺序「遍历 → 算 downstreamKinds → 干跑 → 溯源 → 裁剪 → 汇总」写在
WorkflowChangeManifestBuilder:122。 HitlConfirmNodeExecutor.DEFAULT_APPROVE_VAR = "hitl_approved",全文 71 行。AgentBpmApprovalService.AGENT_CHANGE_MANIFEST_VAR = "agent_change_manifest";BUSINESS_KEY_PREFIX = "agent-wf"。assertPublishable在AgentWorkflowDefServiceImpl:882,只被:335(publishDef)与:727(publishDefById)调用。AgentWorkflowCompensator.IDEMPOTENCY_PARAM = "__idempotencyKey"。handleConfirmWorkflow在AssistantChat.vue:1741,唯一守卫是:1742的if (!item.workflowCode) return。agent_planner_adoptionDDL 在sql/mysql/ainb-init.sql:6087,baseline_dsl在:6091。
推理得出、未取证的:
- 「实测 8 条最大存量流程 manifest 6 条超限(最大 8477 字 = 212%)」转引自
AgentBpmApprovalService:46-47,是当时那次实测的记录,本页未复现。 - 「一次建表编排第 3~6 轮的原文 md5 完全相同(7808df6b…),白烧 4 轮、约 6.4 万 prompt token」转引自
WorkflowPlannerServiceImpl:151-152,本页未复现。 - 「实测 64 行全量盘点 0.6 秒」转引自
assertPublishable注释:871,本页未复现。 - 「双击
确认并运行会起两条执行」是从代码结构推出的(无 in-flight 守卫 + 两个前置 await +run-stream不幂等),本页没有在浏览器里真的双击过。 UntrustedInputTracer(977 行)本体未读,本页只引了它在六步里的位置与「刻意不包 try/catch」那一条。OutputModerator未读,本页只引了三个调用点对它的用法约束(闸门读原始、展示读副本、dsl不审、moderateString非幂等)。- 11 个抽取器只读了
ChangeKind侧的对应关系与GenericToolChangeExtractor的存在理由,方法体未逐个读。 ManifestScoper只读了它在六步里的位置与「不许静默裁」的约束,裁剪算法本身未读。AgentBpmApprovalListener(109 行)未读,本页只引了AgentBpmApprovalService类注释里对它的描述。- 全部测试文件已确认存在,但本页写作时未执行,其断言与实际行为是否一致未验证。
相关页面
- Agent 架构总览 —— 声明值 vs 生效值、架构测试、模块规模
- 工具体系 —— 风险等级怎么来、
requireConfirm的另一半 - 工作流引擎 —— DSL 结构、编译器、
interruptBefore、checkpoint - 安全与提示注入防护 ——
PromptDataFence、OutputModerator、发布闸门的另一半 - 对话与 ReAct loop —— 聊天侧怎么把草稿卡与 HITL 卡投递出去
- 计量与配额 —— 「白烧 4 轮」烧的是什么、
PLAN_DSL场景怎么记 - 前端 SSE 与流式渲染 ——
run-stream/resume-stream的前端那一半 - 使用手册 04 AI · 流程编排 —— 设计器与体检面板长什么样
