fix(codeflow-emit): end 节点的 resultType=error/nop 与 error 载荷被 emit 成裸 return——失败出口静默变 success,run 不再返回错误态
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 3/5
- Estimated time
- Half a day
- Newbie friendliness
- 50/100
Research direction
Start in plaita/dsl/codeflow/_emit.py: the end branch near line 277 calls _emit_return, which only reads output (around line 300). Reproduce with the script in the issue, which shows resultType and error being dropped. The issue offers a minimal fix (raise EmitError when resultType is not success, and add a test to tests/unit/test_codeflow_emit.py) and a full fix; the full fix needs a maintainer decision first. Done means the emit guard works and the new test passes.
Written by the indexing model from the issue text.
Description
优先级 P1 · 依赖:无
基线 HEAD = 65f48f2
Summary
emit_source 在遇到 end 节点时只读取 output 字段,完全不读 resultType 与
error;产出的 @flow 源码里失败出口变成一条普通 return。回编译时
_compile_block 又把这个 return 硬编码回 resultType: "success",于是
resultType=error / nop 的语义在 emit 侧被静默抹掉,而 round-trip 校验只看
「能不能编译」,给不出任何告警。
证据 1:end 节点发射入口只调 _emit_return
plaita/dsl/codeflow/_emit.py:277-279
if ntype == "end":
lines.append(self._emit_return(node))
return lines
证据 2:_emit_return 只读 output,对 resultType/error 零处理
plaita/dsl/codeflow/_emit.py:300-304
def _emit_return(self, node: Dict[str, Any]) -> str:
output = node.get("output")
if output is None:
return "return"
return f"return {_invert_value(output, self.ns)}"
该函数是全模块唯一的 return 发射点(grep -n "_emit_return" plaita/dsl/codeflow/_emit.py
只有 278 的调用与 300 的定义),所以 error / nop 出口没有任何旁路可走。
证据 3:反证——resultType 既不在不支持清单里,也没有任何降级告警
$ grep -n "resultType\|result_type" plaita/dsl/codeflow/_emit.py
(零命中,退出码 1)
$ grep -c "warn" plaita/dsl/codeflow/_emit.py
0
(全模块无任何降级告警分支)
$ grep -n "EmitError" plaita/dsl/codeflow/_emit.py
14:**不支持的构造**(抛 ``EmitError``,不产出错误代码):``switch``/``bool``
24:__all__ = ["EmitError", "emit_source"]
32:class EmitError(ValueError):
127、131、139、194、275、339、341、348、361、430 (各 raise 点)
459: raise EmitError(f"节点类型 {ntype!r} 不可表达为 @flow 源码")
531、553、571(raise)、606(docstring)、609、615(raise)
(共 20 处命中,其中 end 相关为 0)
plaita/dsl/codeflow/_emit.py:29
_UNEMITTABLE_TYPES = {"switch", "bool", "start"}
end 的 resultType 不在该集合中,也没有任何 warning 分支。
模块 docstring 自己列的不支持清单(plaita/dsl/codeflow/_emit.py:14-17):
**不支持的构造**(抛 ``EmitError``,不产出错误代码):``switch``/``bool``
合成节点(builder 专属)、空真分支或两分支同址的 if、非 object 的
``inputType``。IR 里以 ``$`` 开头的字符串一律按表达式逆映射——原始字面量
恰以 ``$`` 开头的场景编译期已丢失区分,属已知边界。
resultType/error 不在清单内,也没有对应的 EmitError 或警告——属静默降级,
而非「已知不可表达」。
证据 4:回编译把每个 return 硬编码成 success
plaita/dsl/codeflow/_stmt.py:44-49
end_id = ctx.semantic_id(f"ret_{ret_slug}" if ret_slug else None)
expr_text = ast.unparse(head.value) if head.value is not None else ""
end_node: Dict[str, Any] = {
"type": "end", "id": end_id,
"output": output, "resultType": "success",
}
证据 5:resultType=error/nop 是引擎一等语义,不是可选注解
plaita/node/end.py:17-18
汇总流程输出:``output`` 表达式的结果作为流程返回值;
``result_type`` 支持 success(正常结束,默认)/ error(以错误结束,配 ``error``)/ nop(静默结束)。
plaita/node/end.py:61-67
def execute(self, execution):
if self.result_type == END_TYPE_NOP:
return None
elif self.result_type == END_TYPE_NORMAL:
return execution.evaluate(self.output)
elif self.result_type == END_TYPE_ERROR:
raise plaita_errors.FlowResultError(self.error.get("code", None), self.error.get("message", None))
证据 6:反证——同 DSL 的其他前端本来就能表达 resultType,缺的是 emit
plaita/dsl/sexpr.py:488-489
rt = _opt(kw, "result-type", "resultType")
spec["resultType"] = str(rt) if rt is not None else "success"
plaita/dsl/sexpr.py:881-882
if n.get("resultType") and n["resultType"] != "success":
parts.append(f":result-type {n['resultType']}")
builder 同样把 result_type 写进 IR(plaita/dsl/builder.py:136):
spec = _node("end", id=id, output=output, resultType=result_type, **extra)
可见「error 出口无法表达」不是整个 DSL 的既有设计约束,而是 emit 一侧漏做。
证据 7:console 表单也允许作者选 resultType
plaita-console/frontend/src/pages/FlowEditor.tsx:264
data: { type: 'end', name: 'end', fields: { output: '$INPUT.name', resultType: 'success' } },
证据 8:引擎测试按 resultType=error 构造 end(现有能力有测试背书)
tests/unit/test_errors.py:23
End(id="end", **{"resultType": "error", "error": {"code": 100, "message": "error"}}),
同类用法另见 tests/unit/test_decide.py:62、tests/unit/test_flow.py:537。
证据 9:复现(实跑,原样贴出)
脚本:
from plaita.dsl.codeflow import emit_source, compile_source
from plaita.dsl.ir_validate import build_flow
from plaita.core.errors import FlowExecutionException
ir = {
"runtime": "python", "flow_id": "errflow", "version": "1",
"inputType": {"dataType": "object"},
"nodes": [
{"type": "start", "id": "start", "next": "c"},
{"type": "if", "id": "c",
"condition": {"field": "$INPUT.ok", "operator": "truthy", "value": True},
"next": "good", "else_next": "bad"},
{"type": "end", "id": "good", "output": "yes", "resultType": "success"},
{"type": "end", "id": "bad", "output": "no", "resultType": "error",
"error": {"code": 4001, "message": "bad"}},
],
}
src = emit_source(ir)
print("--- emitted source ---")
print(src)
print("ends:", [(n["id"], n.get("resultType"), n.get("error"))
for n in compile_source(src)["nodes"] if n["type"] == "end"])
print("--- 原 IR 直接执行(ok=False)---")
try:
print("result:", build_flow(ir).run(inputs_data={"ok": False}))
except FlowExecutionException as e:
print("raised:", type(e).__name__, e.error_type)
print("--- emit→compile 后再执行(ok=False)---")
ir2 = compile_source(src)
ir2.setdefault("version", "1")
try:
print("result:", build_flow(ir2).run(inputs_data={"ok": False}))
except FlowExecutionException as e:
print("raised:", type(e).__name__, e.error_type)
命令与输出:
$ /tmp/plaita-audit-venv/bin/python /tmp/emit_end_repro.py
--- emitted source ---
@flow('errflow', version='1')
def errflow(INPUT):
if INPUT.ok:
return 'yes'
else:
return 'no'
ends: [('ret_yes', 'success', None), ('ret_no', 'success', None)]
--- 原 IR 直接执行(ok=False)---
raised: ErrorResultException FlowErrorType.ERROR_RESULT
--- emit→compile 后再执行(ok=False)---
result: no
即 resultType='error' 与 error={'code': 4001, 'message': 'bad'} 全部丢失,
失败出口变成 success 出口。
证据 10:round-trip 校验只验「可编译」,放行全部丢失
生产入口 plaita-ai/plaita_ai/mcp/server.py:113-128:
try:
source = emit_source(ir)
except EmitError as exc:
return result_json({"ok": False, "error": f"definition cannot be expressed as @flow: {exc}"})
except Exception as exc:
return result_json({"ok": False, "error": f"emit failed: {exc}"})
compiled = compile_flow(source)
payload: Dict[str, Any] = {
"ok": bool(compiled.ok),
"source": source,
"flow_id": (ir or {}).get("flow_id") if isinstance(ir, dict) else None,
}
if not compiled.ok:
payload["error"] = "round-trip compile failed"
payload["errors"] = [e.to_dict() for e in compiled.errors]
return result_json(payload)
return 'no' 完全可编译,所以 ok: true 照发。三个生产调用点
(plaita-ai/plaita_ai/mcp/server.py:114、plaita-ai/plaita_ai/cli/main.py:43-50、
plaita-ai/plaita_ai/supervisor.py:164-166)都没有 resultType 守卫。
影响
谁在什么条件下踩到。 任何把存量 JSON/YAML flow 迁到 @flow DSL 的路径:
plaita-ai MCP 工具 flow_from_json、CLI plaita-ai emit、supervisor 的「取当前
definition 供 LLM 最小改动」,底层都是 emit_source。触发条件只有一个:IR 里存
在 resultType 不是 success 的 end。这类出口是常见写法——校验失败分支、
decide 的 lt_18/fault 出口(tests/unit/test_decide.py:62,115)、builder 的
.end(result_type="error", error=...)、console 表单里改过 resultType 的节点。
代价。
- 失败被记成成功。 迁移前
run()抛FlowExecutionException(error_type=ERROR_RESULT),
迁移后返回'no'正常结束。worker / console / flow_worker 看到的是 success 终态:
重试、告警、回调、issue 回写全部不触发,失败 run 在状态面上与成功 run 无法区分。 nop(静默结束)变成带值成功返回,下游拿到本应被丢弃的中间值。- 错误码与 message 丢失:
{"code": 4001, "message": "bad"}在 emit 产物里不再
存在,事后无法从源码或回编译 IR 里追溯该出口的原始失败原因。 - 不可观测:round-trip 返回
ok: true,用户在迁移时得不到任何提示;错误要到
运行时(且只在踩到该分支时)才以「本该失败却成功」的形式暴露,而那时已经不会再
报错。
可达性。 生产可达:MCP flow_from_json 与 CLI emit 是一等用户入口,非仅库
嵌入。唯一前置条件就是 IR 里存在非 success 的 end——不需要任何特殊配置或开关。
一句话:不修的后果是一次迁移/反编译就把所有失败出口洗成成功出口,且官方
round-trip 校验回 ok: true,系统从此不会报这个错。
建议
最小修法(fail-fast,3 行内)。 在 plaita/dsl/codeflow/_emit.py 的 _emit_return
(300-304)开头加守卫:当 node.get("resultType") 不属于 {None, "success"},或
node.get("error") 非空时,直接 raise EmitError(...),消息里带上节点 id 与
resultType。同时把该构造补进模块 docstring 14-17 的不支持清单。这样
flow_from_json 会退回 ok: false + 明确 error,而不是静默产出语义错误的源码。
完整修法(若要支持迁移)。 给 @flow 增加可表达的失败出口(例如 end() 调用
形态或 return 的 :result-type 注解),并在 _stmt.py:46-49 回编译时把
resultType/error 从源码还原进 end 节点,使
compile_source(emit_source(ir)) 真正满足 _emit.py:6 声称的语义等价。
验收条件。
- 新增
tests/unit/test_codeflow_emit.py::test_emit_rejects_end_error_result_type
(照已有test_emit_rejects_switch_node、test_emit_rejects_if_same_target_branches
的形态写):IR 含{"type": "end", "id": "bad", "output": "no", "resultType": "error", "error": {"code": 4001, "message": "bad"}},断言emit_source抛EmitError。
若采用完整修法,则改为断言compile_source(emit_source(ir))中该 end 的
resultType == "error"且error完整保留;nop同理。 - 端到端断言:本单复现脚本中
emit→compile→run(inputs_data={"ok": False})必须
与原始 IR 一致——FlowExecutionException(error_type=ERROR_RESULT);当前实测
返回'no'。 - 回归:
/tmp/plaita-audit-venv/bin/python -m pytest tests/unit/test_codeflow_emit.py -q
全绿,且plaita-ai侧flow_from_json对该 IR 的返回由ok: true变为
ok: false+ 可读 error(最小修法口径)。
边界
- 只改 emit 一侧对
end出口的处理。不动plaita/node/end.py的引擎语义,不动
sexpr / builder 的前端能力(证据 6 已证明它们本就能表达resultType)。 - 不重构
_emit.py中与 end 无关的发射路径;_emit_return是唯一 return 发射点,
修复集中在该函数(或 277-279 的 end 分支)即可。 - 与 #16「map 子流程 end 递归限制 + if 块作用域隔离」不同根:那条是编译期的作用域与
递归边界,本单是 emit 阶段丢resultType/error字段,两者的触发路径与修法互不重叠。 - 不承诺
nop之外的结束类型语义;不顺手给switch/bool等已知不可表达构造改写
行为。
- Dominant language
- Python
- Stars
- 0
- Forks
- 1
- Avg merge
- 2h 22m
- Merged PRs (30d)
- 8
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from jeffkit/plaita
-
fix(console): #274 并发同 dedup_key 双 POST 各自入队——预认领与占位行非原子,落败 id 永久 queued 且占配额Possibly taken @okguitar claimed this today. Open
Difficulty 5/5 Over a week Newbie friendliness 35/100
jeffkit/plaita#280 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 45/100
Maintainers usually reply within 1 day
-
fix(console): ${redis.url} 从配置文件而非数据面 effective URL 解析——metadata 档 worker 静默连 localhost,warnings 为空Open
Difficulty 4/5 3-5 days Newbie friendliness 48/100
Maintainers usually reply within 1 day
-
Difficulty 3/5 1-2 days Newbie friendliness 55/100
Maintainers usually reply within 1 day
-
refactor(expr): 摘除运行期字符串的二次求值(#104/#130 的终章)——契约定为「配置求值、运行期数据」Possibly taken @jeffkit claimed this today. Open
Difficulty 5/5 Over a week Newbie friendliness 25/100
jeffkit/plaita#268 · 1 comment ·
Maintainers usually reply within 1 day
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 77/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 67/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
pgvector/pgvector-python#169 ·
-
ChaosEngine: backup-remote doctor row passes while consumer mode hides bot memory from gitPossibly taken @MohabMohie claimed this today. Openagentic
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
ShaftHQ/SHAFT_ENGINE#7263 ·
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 87/100
huggingface/smolagents#3045 ·
Maintainers usually reply within 1 day