Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

fix(codeflow-emit): end 节点的 resultType=error/nop 与 error 载荷被 emit 成裸 return——失败出口静默变 success,run 不再返回错误态

Closed
#76 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
3/5
Estimated time
Half a day
Newbie friendliness
50/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Active
Tech stack
python
Domain
compilers

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

needs-human

优先级 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 声称的语义等价。

验收条件。

  1. 新增 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 同理。
  2. 端到端断言:本单复现脚本中 emit→compile→run(inputs_data={"ok": False}) 必须
    与原始 IR 一致——FlowExecutionException(error_type=ERROR_RESULT);当前实测
    返回 'no'。
  3. 回归:/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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from jeffkit/plaita

All issues in jeffkit/plaita

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.