From ebf1d63269562b03da6abc3ab76cce1f2fde2eab Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 19 Oct 2026 08:47:53 +0800 Subject: [PATCH 01/15] =?UTF-8?q?=E5=8A=A9=E6=89=8B=E7=9A=84=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E6=89=8B=E5=86=8C=E8=AE=B2=E9=A9=B3=E5=9B=9E=E5=87=BA?= =?UTF-8?q?=E5=8F=A3=EF=BC=9A=E5=93=AA=E4=BA=9B=E8=8A=82=E7=82=B9=E8=83=BD?= =?UTF-8?q?=E6=8E=A5=EF=BC=88=E5=AE=A1=E6=89=B9=E3=80=81=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E3=80=81=E7=BC=96=E8=BE=91=E7=A8=BF=E4=BB=B6?= =?UTF-8?q?=E3=80=81=E5=8A=A8=E4=BD=9C=E7=A1=AE=E8=AE=A4=E3=80=81=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E5=92=8C=E5=B7=A5=E5=85=B7=E5=AE=A1=E6=89=B9=E3=80=81?= =?UTF-8?q?=E5=8F=8C=E7=9B=B2=E8=A3=81=E5=86=B3=EF=BC=89=EF=BC=8C=E8=BF=9E?= =?UTF-8?q?=E7=BA=BF=E5=86=99=20sourceHandle=3D"rejected"=EF=BC=8C?= =?UTF-8?q?=E4=BB=80=E4=B9=88=E6=97=B6=E5=80=99=E6=8E=A5=EF=BC=88=E9=A9=B3?= =?UTF-8?q?=E5=9B=9E=E5=90=8E=E8=A6=81=E9=80=9A=E7=9F=A5=E3=80=81=E9=80=80?= =?UTF-8?q?=E5=9B=9E=E4=BF=AE=E6=94=B9=E3=80=81=E8=B5=B0=E5=8F=A6=E4=B8=80?= =?UTF-8?q?=E6=9D=A1=E8=B7=AF=EF=BC=89=EF=BC=8C=E4=B8=8D=E6=8E=A5=E6=97=B6?= =?UTF-8?q?=E6=80=8E=E6=A0=B7=EF=BC=88=E8=BF=90=E8=A1=8C=E7=BB=93=E6=9D=9F?= =?UTF-8?q?=E3=80=81=E8=AE=B0=E4=B8=BA=E8=A2=AB=E9=A9=B3=E5=9B=9E=EF=BC=9B?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E6=A8=A1=E5=BC=8F=E8=BF=99=E4=B8=80=E8=B7=AF?= =?UTF-8?q?=E5=88=B0=E6=AD=A4=E4=B8=BA=E6=AD=A2=EF=BC=9B=E5=86=99=E4=BA=86?= =?UTF-8?q?=E9=A9=B3=E5=9B=9E=E5=8D=B3=E7=BB=88=E6=AD=A2=E5=B0=B1=E5=88=AB?= =?UTF-8?q?=E6=8E=A5=EF=BC=89=EF=BC=8C=E9=A9=B3=E5=9B=9E=E9=82=A3=E6=9D=A1?= =?UTF-8?q?=E8=B7=AF=E5=BF=85=E9=A1=BB=E8=B5=B0=E5=88=B0=E6=88=90=E6=9E=9C?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E6=88=96=E7=BB=93=E6=9D=9F=EF=BC=8C=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E9=87=8D=E8=AF=95=E3=80=81=E5=87=BA=E9=94=99=E6=97=B6?= =?UTF-8?q?=E7=BB=A7=E7=BB=AD=E4=B8=8D=E7=AE=A1=E9=A9=B3=E5=9B=9E=E3=80=82?= =?UTF-8?q?=E4=BB=A5=E5=89=8D=E6=89=8B=E5=86=8C=E5=8F=AA=E6=9C=89=E4=B8=80?= =?UTF-8?q?=E5=8F=A5=E5=AE=A1=E6=89=B9=E7=9A=84=20approved=20/=20rejected?= =?UTF-8?q?=EF=BC=8C=E5=8A=A9=E6=89=8B=E7=94=BB=E5=87=BA=E6=9D=A5=E7=9A=84?= =?UTF-8?q?=E5=9B=BE=E9=A9=B3=E5=9B=9E=E5=B0=B1=E6=95=B4=E8=BD=AE=E7=BB=93?= =?UTF-8?q?=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/copilot.py | 14 +++- .../tests/test_copilot_reject_exit_guide.py | 84 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_copilot_reject_exit_guide.py diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py index 63ee57f6..540c082a 100644 --- a/backend/app/api/copilot.py +++ b/backend/app/api/copilot.py @@ -327,7 +327,19 @@ skip_if。这些字段按受限的 Python 表达式求值,变量直接写路 - edges 里每条边 {source, target, sourceHandle?} - branch 节点的出边必须带 sourceHandle,取值是 cases 里的 key,另外要有一条 sourceHandle="default" 兜底 - loop 节点:sourceHandle="body" 连循环体,循环体最后一个节点再连回 loop 节点;sourceHandle="done" 连循环结束后的去向 -- human 节点(approve 模式):sourceHandle 用 "approved" 和 "rejected" +- human 节点(approve 模式):批准走 sourceHandle "approved",驳回走下面的「驳回」出口 +- 「驳回」出口:会被人驳回的节点可以多接一条 sourceHandle="rejected" 的边,驳回(包括超时驳回)之后沿它往下走。 + 能接的只有:human(审批、补充输入、编辑稿件三种模式)、tool(工具调用审批、写外部系统前的动作确认)、 + code(代码执行审批)、dual_check(双盲复算的裁决);别的节点接了画布校验会报错。 + · 什么时候接:驳回之后还要做事——通知申请人(接一个通知或写说明的节点,再到成果节点)、退回修改(连回要改的上游 + 那一步,改完再到这个节点问一次)、改走另一条路(比如出一份「已退回」的成果)。驳回时这一步照常结束,下游用 + {{ nodes.关卡id.note }} 取驳回备注、{{ nodes.关卡id.actor }} 取谁驳回的;动作确认被驳回时写入不会发出 + · 不接时:补充输入、编辑稿件、动作确认、代码和工具审批、双盲裁决被驳回,运行就此结束、记为被驳回(是人的决定,不算失败); + 审批模式不接时这一路到审批为止、不再往下走(运行照常结束,没有成果)。审批写了 stop_on_reject(驳回即终止运行)就不要再接驳回出口: + 驳回时运行直接结束,那条边走不到 + · 驳回那条路必须走到成果节点,或者在一个不再往下连的节点结束(退回修改绕回上游、改完再问一次也算),否则驳回之后 + 只能一直绕下去,发布检查会报错 + · 驳回不是故障:「失败重试」「出错时继续」不管驳回,不要为了驳回之后接着走去配它们 - 一个节点连出多条普通边 = 并行执行,多条边汇入同一节点 = 等待汇聚 """ diff --git a/backend/tests/test_copilot_reject_exit_guide.py b/backend/tests/test_copilot_reject_exit_guide.py new file mode 100644 index 00000000..e9b9cee7 --- /dev/null +++ b/backend/tests/test_copilot_reject_exit_guide.py @@ -0,0 +1,84 @@ +"""助手的节点手册要教「驳回」出口(docs/design/orchestration-round/00-总方案.md §3)。 + +手册里没写的出口,助手就不会接:驳回之后要通知申请人、退回修改、改走另一条路的,画出来的图只会在驳回时整轮结束。 +这里不只查关键词,还拿校验认的那一份(会被驳回的节点类型、出口的 sourceHandle、发布检查的「走不到结束」)来对, +再照手册的写法搭一张图,校验、发布检查都过。 +""" +from __future__ import annotations + +import re + +from app.api.copilot import NODE_REFERENCE +from app.engine.schema import GATE_TYPES, REJECT_HANDLE, GraphSpec, validate_graph + + +def _reject_entry() -> str: + start = NODE_REFERENCE.index("「驳回」出口") + return NODE_REFERENCE[start:NODE_REFERENCE.index("\n- 一个节点连出多条普通边", start)] + + +def test_the_guide_names_every_node_that_can_be_rejected_and_the_handle(): + entry = _reject_entry() + assert f'sourceHandle="{REJECT_HANDLE}"' in entry + # 能接驳回出口的节点类型(schema.GATE_TYPES)一个不漏,按 type 字段写 + assert all(f"{t}" in entry for t in GATE_TYPES), [t for t in GATE_TYPES if str(t) not in entry] + # 六种关卡都点到:审批、补充输入、编辑稿件、动作确认、代码 / 工具审批、双盲裁决 + for kind in ("审批", "补充输入", "编辑稿件", "动作确认", "代码执行审批", "工具调用审批", "裁决"): + assert kind in entry, kind + + +def test_the_guide_says_when_to_connect_it_and_what_happens_without_it(): + entry = _reject_entry() + # 什么时候接:驳回后要通知、退回修改、走另一条路 + assert "通知" in entry and "退回修改" in entry and "另一条路" in entry + # 不接时:运行结束、记被驳回(不算失败);审批模式没设驳回即终止的老规矩照实说 + assert "记为被驳回" in entry and "不算失败" in entry + assert "stop_on_reject" in entry + # 驳回那条路必须走到成果节点或结束(发布检查 reject_exit.no_end),绕回去改完再问一次也可以 + assert "成果节点" in entry and "不再往下连" in entry and "发布检查" in entry + # 驳回不是故障:失败重试、出错时继续不管它 + assert "失败重试" in entry + # 驳回之后下游取得到谁、备注 + assert "{{ nodes.关卡id.note }}" in entry + + +def test_the_guide_does_not_leak_internal_names(): + entry = _reject_entry() + for internal in ("reject_exit", "NodeRejected", "end_reason", "cancelled", "__decision__", "GATE_TYPES"): + assert internal not in entry, internal + + +def test_the_old_one_line_rule_is_replaced_not_duplicated(): + # 以前只有一句「human 节点(approve 模式):sourceHandle 用 approved 和 rejected」:驳回出口说一处,不留两种说法 + assert len(re.findall(r'sourceHandle="rejected"', NODE_REFERENCE)) == 1 + + +def _node(nid: str, ntype: str, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}} + + +def test_a_graph_drawn_the_way_the_guide_says_passes_the_checks(): + """照手册写:审批批准走 approved,驳回接 sourceHandle="rejected" 到「通知申请人」再到一个成果节点;工具的确认被驳回 + 退回上游修改(连回起草,改完再问一次)。画布校验、各级发布检查都不报驳回出口的问题。""" + from app.engine.governance import lint_for_publish + + graph = { + "nodes": [_node("start", "input"), _node("draft", "llm", prompt="起草"), + _node("gate", "human", mode="approve", title="可以吗"), + _node("send", "tool", tool="calculator", args={"expression": "1+1"}, approval="always"), + _node("tell", "llm", prompt="通知申请人:{{ nodes.gate.note }}"), + _node("done", "output", fields=[{"name": "结果", "value": "{{ nodes.send.text }}"}]), + _node("back", "output", fields=[{"name": "结果", "value": "{{ nodes.tell.text }}"}])], + "edges": [{"source": "start", "target": "draft"}, {"source": "draft", "target": "gate"}, + {"source": "gate", "target": "send", "sourceHandle": "approved"}, + {"source": "gate", "target": "tell", "sourceHandle": "rejected"}, + {"source": "tell", "target": "back"}, + {"source": "send", "target": "done"}, + {"source": "send", "target": "draft", "sourceHandle": "rejected"}], + } + spec = GraphSpec.model_validate(graph) + codes = {i.code for i in validate_graph(spec).issues if i.level == "error"} + assert not {c for c in codes if c and "reject" in c}, codes + for level in ("draft", "published", "governed"): + issues = lint_for_publish(spec, level=level).issues + assert not [i for i in issues if (i.code or "").startswith("reject_exit")], level From 17e99cd76c9fc84a1e5ca93de4f963510589839f Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 19 Oct 2026 08:59:15 +0800 Subject: [PATCH 02/15] =?UTF-8?q?=E9=A9=B3=E5=9B=9E=E5=87=BA=E5=8F=A3?= =?UTF-8?q?=E4=B9=8B=E5=90=8E=EF=BC=8C=E5=BE=85=E5=8A=9E=E3=80=81=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E3=80=81Webhook=20=E7=85=A7=E5=AE=9E=E8=AF=B4?= =?UTF-8?q?=E8=BD=AC=E5=88=B0=E5=93=AA=EF=BC=9A=E4=B8=AD=E6=96=AD=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E5=B8=A6=E4=B8=8A=E9=A9=B3=E5=9B=9E=E4=B9=8B=E5=90=8E?= =?UTF-8?q?=E5=8E=BB=E5=93=AA=EF=BC=88engine/replay.reject=5Froute?= =?UTF-8?q?=EF=BC=9A=E6=8E=A5=E4=BA=86=E9=A9=B3=E5=9B=9E=E5=87=BA=E5=8F=A3?= =?UTF-8?q?=E5=86=99=E8=BD=AC=E5=88=B0=E5=93=AA=E5=87=A0=E6=AD=A5=EF=BC=8C?= =?UTF-8?q?=E6=B2=A1=E6=8E=A5=E7=9A=84=E5=86=99=E8=BF=90=E8=A1=8C=E5=B0=B1?= =?UTF-8?q?=E6=AD=A4=E7=BB=93=E6=9D=9F=EF=BC=8C=E5=AE=A1=E6=89=B9=E6=B2=A1?= =?UTF-8?q?=E6=8E=A5=E3=80=81=E6=B2=A1=E8=AE=BE=E9=A9=B3=E5=9B=9E=E5=8D=B3?= =?UTF-8?q?=E7=BB=88=E6=AD=A2=E7=9A=84=E5=86=99=E8=BF=99=E4=B8=80=E8=B7=AF?= =?UTF-8?q?=E5=88=B0=E6=AD=A4=E4=B8=BA=E6=AD=A2=EF=BC=89=EF=BC=8C=E5=BE=85?= =?UTF-8?q?=E5=8A=9E=E5=8D=A1=E7=9A=84=E6=8F=90=E7=A4=BA=E3=80=81=E9=A9=B3?= =?UTF-8?q?=E5=9B=9E=E6=8C=89=E9=92=AE=E3=80=81=E9=A9=B3=E5=9B=9E=E5=90=8E?= =?UTF-8?q?=E7=9A=84=E6=8F=90=E7=A4=BA=E3=80=81=E5=A4=84=E7=90=86=E8=BF=87?= =?UTF-8?q?=E7=9A=84=E3=80=8C=E7=BB=93=E6=9E=9C=E3=80=8D=E7=85=A7=E5=AE=83?= =?UTF-8?q?=E8=AF=B4=EF=BC=8C=E4=B8=8D=E5=86=8D=E4=B8=80=E5=BE=8B=E5=86=99?= =?UTF-8?q?=E3=80=8C=E8=BD=AC=E5=85=A5=E9=A9=B3=E5=9B=9E=E5=88=86=E6=94=AF?= =?UTF-8?q?=E3=80=8D=E3=80=8C=E6=9C=AC=E6=AC=A1=E8=BF=90=E8=A1=8C=E7=BB=88?= =?UTF-8?q?=E6=AD=A2=E3=80=8D=EF=BC=9B=E8=B6=85=E6=97=B6=E9=A9=B3=E5=9B=9E?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E9=9C=B2=E5=87=BA=E8=8B=B1=E6=96=87=E7=BB=93?= =?UTF-8?q?=E8=AE=BA=E7=A0=81=EF=BC=9B=E5=8F=8C=E7=9B=B2=E8=A3=81=E5=86=B3?= =?UTF-8?q?=E3=80=81=E5=8A=A8=E4=BD=9C=E7=A1=AE=E8=AE=A4=E8=A2=AB=E9=A9=B3?= =?UTF-8?q?=E5=9B=9E=E7=9A=84=E6=8F=90=E7=A4=BA=E5=90=8C=E6=A0=B7=E7=85=A7?= =?UTF-8?q?=E5=AE=83=E8=AF=B4=E3=80=82=E5=AD=90=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=E9=87=8C=E7=9A=84=E5=85=B3=E5=8D=A1=E8=B5=B0=E9=A9=B3=E5=9B=9E?= =?UTF-8?q?=E5=87=BA=E5=8F=A3=E6=97=B6=EF=BC=8C=E5=90=8D=E5=AD=97=E8=AE=B0?= =?UTF-8?q?=E6=88=90=E3=80=8C=E5=AD=90=E5=B7=A5=E4=BD=9C=E6=B5=81=20?= =?UTF-8?q?=E2=80=BA=20x=E3=80=8D=EF=BC=88RunContext.global=5Flabel?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E8=B5=B0=E8=BF=87=E7=9A=84=E9=82=A3=E5=87=A0?= =?UTF-8?q?=E6=AD=A5=E4=BA=A4=E5=9B=9E=E7=88=B6=E5=9B=BE=E7=9A=84=20trail?= =?UTF-8?q?=EF=BC=8C=E7=88=B6=E8=BF=90=E8=A1=8C=E7=9A=84=E7=BB=88=E6=80=81?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E7=85=A7=E5=AE=9E=E5=B8=A6=E4=B8=8A=E9=80=94?= =?UTF-8?q?=E7=BB=8F=E9=A9=B3=E5=9B=9E=EF=BC=8C=E5=AE=9E=E4=BE=8B=E9=A1=B5?= =?UTF-8?q?=E3=80=81=E9=80=9A=E7=9F=A5=E5=86=99=E3=80=8C=E3=80=8C=E5=AD=90?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=20=E2=80=BA=20=E6=8A=8A=E5=85=B3?= =?UTF-8?q?=E3=80=8D=E8=A2=AB=E9=A9=B3=E5=9B=9E=EF=BC=8C=E8=BD=AC=E5=88=B0?= =?UTF-8?q?=E3=80=8C=E5=AD=90=E5=B7=A5=E4=BD=9C=E6=B5=81=20=E2=80=BA=20?= =?UTF-8?q?=E9=80=80=E5=9B=9E=E3=80=8D=E3=80=8D=EF=BC=8C=E4=B8=8D=E5=86=99?= =?UTF-8?q?=E5=86=85=E9=83=A8=20id=E3=80=81=E4=B8=8D=E5=86=99=E6=88=90?= =?UTF-8?q?=E5=B7=B2=E5=AE=8C=E6=88=90=E3=80=82=E5=AE=9A=E6=97=B6=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E7=9A=84=20Webhook=EF=BC=9A=E7=85=A7=E9=A9=B3?= =?UTF-8?q?=E5=9B=9E=E9=82=A3=E6=9D=A1=E8=B7=AF=E6=88=90=E5=8A=9F=E7=BB=93?= =?UTF-8?q?=E6=9D=9F=E7=9A=84=EF=BC=8C=E6=A0=87=E9=A2=98=E3=80=81=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=86=99=E9=80=94=E7=BB=8F=E9=A9=B3=E5=9B=9E=EF=BC=8C?= =?UTF-8?q?=E5=8F=A6=E8=B5=B7=E4=B8=80=E8=A1=8C=E5=86=99=E8=B0=81=E8=A2=AB?= =?UTF-8?q?=E9=A9=B3=E5=9B=9E=E3=80=81=E8=BD=AC=E5=88=B0=E5=93=AA=E4=B8=80?= =?UTF-8?q?=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/apps/resolve.py | 4 + backend/app/engine/compiler.py | 10 +- backend/app/engine/context.py | 7 + backend/app/engine/nodes/multi.py | 16 +- backend/app/engine/replay.py | 30 +++- backend/app/scheduler/webhook.py | 31 ++++ backend/tests/test_dual_check_node.py | 6 +- backend/tests/test_reject_exit_wording.py | 197 ++++++++++++++++++++++ frontend/src/lib/terms.ts | 2 + frontend/src/lib/termsApp.ts | 36 +++- frontend/src/run/DualCheckApproval.tsx | 12 +- frontend/src/run/TaskCard.tsx | 26 ++- scripts/check-inbox.mjs | 52 ++++++ 13 files changed, 410 insertions(+), 19 deletions(-) create mode 100644 backend/tests/test_reject_exit_wording.py diff --git a/backend/app/apps/resolve.py b/backend/app/apps/resolve.py index 1f45c938..cd260aa5 100644 --- a/backend/app/apps/resolve.py +++ b/backend/app/apps/resolve.py @@ -1582,6 +1582,10 @@ async def _rejected_exits(session: AsyncSession, runs: list[Run], out: dict[str, names = {str(v.get("node_id")) for v in via or [] if isinstance(v, dict) and v.get("node_id")} if names: gates[run_id] = names + for v in via or []: + # 子工作流里的关卡(「sub/x」)不在运行的图上:名字照终态事件上记的(「子工作流 › x」),不写内部 id + if isinstance(v, dict) and v.get("node_id") and isinstance(v.get("label"), str) and v["label"]: + out[run_id].end_labels.setdefault(str(v["node_id"]), v["label"]) if not gates: return wanted = sorted({n for names in gates.values() for n in names}) diff --git a/backend/app/engine/compiler.py b/backend/app/engine/compiler.py index bfc48df8..25dd3c17 100644 --- a/backend/app/engine/compiler.py +++ b/backend/app/engine/compiler.py @@ -136,7 +136,8 @@ def _wrap( "attempt": attempt + 1, "ts": time.time(), } - return {**updates, "trail": [trail]} + # 节点自己交回的 trail(子工作流里走了驳回出口的那几步,multi._rejected_steps)排在这一步前面 + return {**updates, "trail": [*(updates.get("trail") or []), trail]} except GraphBubbleUp: # interrupt() / Command 靠异常向上传递控制流,不是执行失败: # 必须原样放行,否则人工介入会被当成节点报错吞掉。 @@ -230,7 +231,10 @@ async def _rejected_exit(node: GraphNode, ctx: NodeContext, error: NodeRejected, # 节点在驳回之前已经算出的(NodeRejected.exit_updates:双盲复算的出具缺口、台账条目)照样交出;驳回的那几个键在上面 payload = {**(extra.get("payload") or {}), **error.exit_payload()} nodes = run_ctx.spec.node_map() - to = [{"node_id": run_ctx.global_id(t), "label": nodes[t].title if t in nodes else t} for t in dict.fromkeys(targets)] + # 名字按这次运行的说法记(子工作流里的写「子工作流 › x」,RunContext.global_label):父运行的图上没有子工作流里的节点, + # 实例页、通知、待办、Webhook 都照这个名字说 + to = [{"node_id": run_ctx.global_id(t), "label": run_ctx.global_label(nodes[t].title if t in nodes else t)} + for t in dict.fromkeys(targets)] try: artifact_id: str | None = await put_json(payload, kind="node_output", run_id=run_ctx.run_id, node_id=run_ctx.global_id(node.id), @@ -244,7 +248,7 @@ async def _rejected_exit(node: GraphNode, ctx: NodeContext, error: NodeRejected, outcome="rejected", **said, to=to, **({"evidence": ledger} if ledger else {})) updates: dict[str, Any] = {"nodes": {node.id: payload}, "trail": [{ "node_id": node.id, "type": str(node.type), "duration_ms": elapsed, "attempt": attempt, "ts": time.time(), - "outcome": "rejected", "label": node.title, **said, "to": to}]} + "outcome": "rejected", "label": run_ctx.global_label(node.title), **said, "to": to}]} if ledger: updates["evidence"] = ledger if extra.get("usage"): diff --git a/backend/app/engine/context.py b/backend/app/engine/context.py index fb85a203..6aff018f 100644 --- a/backend/app/engine/context.py +++ b/backend/app/engine/context.py @@ -43,9 +43,16 @@ class RunContext: """这张图里的节点 id → 这次运行里的 id(事件、工件上记的那个):最外层原样,子工作流里加前缀。""" return f"{self.prefix}{node_id}" + def global_label(self, title: str) -> str: + """这张图里的节点标题 → 这次运行里给人看的名字:最外层原样,子工作流里接上子工作流的名字(「子工作流 › x」,嵌套的 + 一层层接上,同 multi.run_subgraph 记在异常上的 label)。父运行的图上没有子工作流里的节点,各处照这个名字说。""" + return f"{self.extra.get(LABEL_PREFIX_KEY) or ''}{title}" + #: 子工作流里的节点 id 前缀(RunContext.extra 里的键,engine/nodes/multi.run_subgraph 放) PREFIX_KEY = "node_prefix" +#: 子工作流里的节点名前缀(「子工作流 › 」,同上):RunContext.global_label +LABEL_PREFIX_KEY = "node_label_prefix" #: 父运行的事件流(RunContext.extra 里的键,同上)。子工作流在父节点里用 ainvoke 跑,LangGraph 不把它的 custom 事件交给 #: 父图的流(ainvoke 不开 custom 模式,get_stream_writer 拿到的是空的那个):子工作流里每一步以前在运行的事件里一条都 #: 没有,时间线看不到、封存也不含它查过的数。所以 run_subgraph 把父图那一刻的写入口交下来,子工作流的节点经它发 diff --git a/backend/app/engine/nodes/multi.py b/backend/app/engine/nodes/multi.py index 80b20064..17222b1d 100644 --- a/backend/app/engine/nodes/multi.py +++ b/backend/app/engine/nodes/multi.py @@ -14,7 +14,7 @@ from app.core.errors import describe_exception, raw_detail from app.core.events import EventType from app.db.base import SessionLocal from app.db.models import Workflow -from app.engine.context import PREFIX_KEY, STREAM_KEY, NodeContext, NodeError, stream_writer +from app.engine.context import LABEL_PREFIX_KEY, PREFIX_KEY, STREAM_KEY, NodeContext, NodeError, stream_writer from app.engine.lineage import whole_ref from app.engine.state import GraphState, message_text, template_context from app.engine.toolcalls import ( @@ -691,7 +691,8 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 恢复重放的标记和签批人只属于父图的节点,不能漏进子图(节点 id 可能重名)。前缀和写入口见 engine/context: # 子图里的节点经父运行的写入口发事件、节点 id 带前缀;嵌套的子图拿到的是同一个写入口、更长的前缀 extra={**{k: v for k, v in ctx.run.extra.items() if k not in ("resumed", "actors")}, - PREFIX_KEY: f"{ctx.run.global_id(ctx.node.id)}/", STREAM_KEY: stream_writer(ctx.run)}, + PREFIX_KEY: f"{ctx.run.global_id(ctx.node.id)}/", LABEL_PREFIX_KEY: f"{ctx.run.global_label(ctx.node.title)} › ", + STREAM_KEY: stream_writer(ctx.run)}, ) ctx.emit(EventType.LOG, level="info", @@ -753,6 +754,11 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: } if ledger := [_forwarded(e, ctx.node.id) for e in result_state.get("evidence") or [] if isinstance(e, dict)]: updates["evidence"] = ledger + if rejected := _rejected_steps(result_state, ctx.node.id): + # 子工作流里走了驳回出口的那几步(engine/compiler._rejected_exit 记的 outcome=rejected)交回父图的 trail:运行收尾 + # 据此在终态事件上照实写「途经驳回」(runner._rejected_via),实例页、通知、定时任务的 Webhook 说得出「子工作流 › 把关」 + # 被驳回、转到哪。名字、转到哪几步在子图里已经按这次运行的说法记好(global_label、global_id),这里只接上节点 id 的前缀 + updates["trail"] = rejected var_name = ctx.cfg("assign_to", "") if var_name: updates["vars"] = {var_name: output} @@ -763,6 +769,12 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: _LEDGER_NODE_KEYS = ("node_id", "card") +def _rejected_steps(sub_state: dict[str, Any], node_id: str) -> list[dict[str, Any]]: + """子工作流的 trail 里走了驳回出口的那几步,节点 id 换成父图这一层的(「子工作流节点 id/…」)。""" + return [{**t, "node_id": f"{node_id}/{t.get('node_id')}"} for t in sub_state.get("trail") or [] + if isinstance(t, dict) and t.get("outcome") == "rejected"] + + def _forwarded(entry: dict[str, Any], node_id: str) -> dict[str, Any]: """子图台账里的一条交回父图:节点 id 加上「子工作流节点 id/」前缀,别的照抄。""" out = dict(entry) diff --git a/backend/app/engine/replay.py b/backend/app/engine/replay.py index 3d4d1078..e95e2446 100644 --- a/backend/app/engine/replay.py +++ b/backend/app/engine/replay.py @@ -75,4 +75,32 @@ async def ask(ctx: NodeContext, payload: dict[str, Any], **request: Any) -> Any: 答复真正到达的那次执行里出现。 """ await once(ctx, EventType.HUMAN_REQUESTED, **request) - return interrupt(payload) + route = reject_route(ctx) if isinstance(payload, dict) else None + return interrupt({**payload, "reject_route": route} if route else payload) + + +def reject_route(ctx: NodeContext) -> dict[str, Any] | None: + """驳回之后去哪(总方案 §3 的驳回出口),放进中断内容:待办卡的提示、驳回之后的那句、处理过的结果照它说,不再一律写 + 「转入驳回分支」或「本次运行终止」。 + + - {"to": [{node_id, label}]}:接了驳回出口,驳回后转到这几步(节点 id、名字按这次运行的说法:子工作流里的写 + 「子工作流 › x」,同 compiler._rejected_exit 记在 node.finished 上的 to); + - {"ends": "run"}:运行就此结束、记为被驳回(没接驳回出口的关卡;审批设了驳回即终止时驳回出口走不到,也是这样); + - {"ends": "path"}:审批模式没接驳回出口、没设驳回即终止:这一路到审批为止,不再往下走(engine/nodes/human)。 + + 不是关卡的节点(agent 自己调工具时的审批:驳回了 agent 照常往下想)为 None,中断内容和以前一字不差。""" + from app.engine.schema import GATE_TYPES, approve_mode, reject_targets + + node, spec = ctx.node, ctx.run.spec + if node.type not in GATE_TYPES: + return None + approving = approve_mode(spec, node) + if approving and bool(ctx.cfg("stop_on_reject", False)): + return {"ends": "run"} + targets = reject_targets(spec, node) + if targets: + nodes = spec.node_map() + return {"to": [{"node_id": ctx.run.global_id(t), + "label": ctx.run.global_label(nodes[t].title if t in nodes else t)} + for t in dict.fromkeys(targets)]} + return {"ends": "path" if approving else "run"} diff --git a/backend/app/scheduler/webhook.py b/backend/app/scheduler/webhook.py index c911b118..7c32d96f 100644 --- a/backend/app/scheduler/webhook.py +++ b/backend/app/scheduler/webhook.py @@ -77,6 +77,8 @@ RERUN_ACTION = "如需重新发起,请在 AgentLab 的「定时任务」中点 REJECTED_HEADLINE = "未出具(被驳回)" REJECTED_STATUS = "被驳回" REJECTED_ACTION = "驳回的说明请在 AgentLab 的「记录」中查看" +#: 照驳回那条路成功结束的(驳回出口):标题、状态后面接这一句 +VIA_REJECTED = "(途经驳回)" # -------------------------------------------------------------------------- @@ -118,6 +120,9 @@ class Notice: error_node: str | None = None #: 运行结束在 cancelled 的原因(Run.end_reason):rejected 时按「未出具(被驳回)」说,error_node 是驳回的那一步 end_reason: str | None = None + #: 照驳回那条路成功结束的运行途经的驳回出口(run.finished 的 rejected_via,engine/runner._rejected_via):标题、状态写 + #: 途经驳回,另起一行写谁被驳回、转到哪一步(同实例页 apps/resolve.rejected_exit_text)。没途经驳回的为 None + rejected_via: list[dict[str, Any]] | None = None next_fire: bool = False #: 事件触发的那一次(fire_kind=event):事件的说法(scheduler/triggers.event_text),不含出口字段的值、变化的内容 trigger_text: str | None = None @@ -130,6 +135,11 @@ class Notice: def rejected(self) -> bool: return self.status == "cancelled" and self.end_reason == "rejected" + @property + def via_rejection(self) -> bool: + """成功结束、途经驳回出口(审批接了驳回出口、按驳回那条路走完):状态是 succeeded,但不能只说「运行完成」。""" + return self.status == "succeeded" and bool(self.rejected_via) + @property def never_started(self) -> bool: """失败的运行从没开始执行(没有开始时间):服务重启时还在排队、被改成失败的就是这种,没有断点可续。""" @@ -160,10 +170,18 @@ def message(notice: Notice) -> tuple[str, list[tuple[str, str]], str | None]: headline = REJECTED_HEADLINE else: headline = _HEADLINE.get(notice.event, notice.event) + if notice.via_rejection: + headline += VIA_REJECTED title = f"定时任务「{notice.schedule_name}」{headline}" lines: list[tuple[str, str]] = [] if notice.status: lines.append(("状态", _status_label(notice))) + if notice.via_rejection: + from app.apps.resolve import rejected_exit_text + + # 一个关卡一句:「「把关」被驳回,转到「退回申请人」」(名字是搭图时起的,不带查询到的数据) + lines.append(("驳回", ";".join(f"「{str(v.get('label') or v.get('node_id') or '')[:80]}」{rejected_exit_text(v)}" + for v in notice.rejected_via or [] if isinstance(v, dict)))) if notice.tier: gaps = f"(缺口 {notice.gaps} 条)" if notice.gaps is not None else "" lines.append(("出具档位", f"{ISSUANCE_LABEL.get(notice.tier, notice.tier)}{gaps}")) @@ -244,6 +262,8 @@ def _status_label(notice: Notice) -> str: return "已挂起,等待核实" if notice.action_pending else "已挂起,可继续运行" if notice.rejected: return REJECTED_STATUS + if notice.via_rejection: + return f"{_STATUS_LABEL['succeeded']}{VIA_REJECTED}" return _STATUS_LABEL.get(notice.status or "", notice.status or "") @@ -554,12 +574,23 @@ async def _notice(session: Any, schedule: Schedule, fire: ScheduleFire, run: Run error_node=_failed_node(run, await _end_label(session, run)) if run is not None and (run.status == "failed" or run.end_reason == "rejected") else None, end_reason=run.end_reason if run is not None else None, + rejected_via=await _rejected_via_of(session, run) if run is not None and run.status == "succeeded" else None, # 事件触发的任务没有下一次「到点」,启用着就是下一次事件来了照常发起 next_fire=bool(schedule.enabled and (schedule.next_run_at is not None or schedule.trigger_kind == "event")), trigger_text=_trigger_text(fire), ) +async def _rejected_via_of(session: Any, run: Run) -> list[dict[str, Any]] | None: + """这次运行途经的驳回出口:最后一条 run.finished 上的 rejected_via(子工作流里的那几步名字写「子工作流 › x」),没有为 None。""" + data = await session.scalar(select(RunEvent.data).where(RunEvent.run_id == run.id, RunEvent.type == "run.finished") + .order_by(RunEvent.seq.desc()).limit(1)) + via = data.get("rejected_via") if isinstance(data, dict) else None + if not isinstance(via, list): + return None + return [v for v in via if isinstance(v, dict)] or None + + def _trigger_text(fire: ScheduleFire) -> str | None: event = fire.inputs.get("event") if isinstance(fire.inputs, dict) else None text = event.get("text") if isinstance(event, dict) else None diff --git a/backend/tests/test_dual_check_node.py b/backend/tests/test_dual_check_node.py index 01b207b7..f7f2d1f0 100644 --- a/backend/tests/test_dual_check_node.py +++ b/backend/tests/test_dual_check_node.py @@ -376,8 +376,10 @@ async def test_disagreement_pauses_with_the_agreed_payload_and_card(monkeypatch, approval = await pending(run.id) assert approval.mode == "input" and approval.node_id == "dual" payload = approval.payload - # 约定的六个键,另加 on_reject(实际生效的处置):审核人在卡上要看得出没采用原流程会不会让运行失败 - assert set(payload) == {"kind", "node_id", "mode", "title", "card_artifact", "summary", "on_reject"} + # 约定的六个键,另加 on_reject(实际生效的处置):审核人在卡上要看得出没采用原流程会不会让运行失败;reject_route + # (驳回之后去哪,engine/replay.reject_route):没接驳回出口,驳回后运行就此结束 + assert set(payload) == {"kind", "node_id", "mode", "title", "card_artifact", "summary", "on_reject", "reject_route"} + assert payload["reject_route"] == {"ends": "run"} assert payload["kind"] == "dual_check" and payload["node_id"] == "dual" and payload["mode"] == "input" assert payload["on_reject"] == "degrade" [item] = payload["summary"] diff --git a/backend/tests/test_reject_exit_wording.py b/backend/tests/test_reject_exit_wording.py new file mode 100644 index 00000000..5f072208 --- /dev/null +++ b/backend/tests/test_reject_exit_wording.py @@ -0,0 +1,197 @@ +"""驳回出口之后,待办、通知、定时任务的 Webhook 照实说「被驳回,转到 X」(docs/design/orchestration-round/00-总方案.md §3)。 + +实例页已经这么说(test_reject_exit.test_the_instance_says_rejected_and_where_it_went)。这里对的是另外几处: +- 待办:中断内容带着驳回之后去哪(reject_route):接了驳回出口的写转到哪几步,没接的写运行就此结束,审批模式没接、没设 + 驳回即终止的写这一路到此为止。待办卡的提示、驳回后的提示、处理过的结果照它说,不再一律写「转入驳回分支」「本次运行终止」; +- 子工作流里的关卡走了驳回出口:父运行的终态事件照实带上(「子工作流 › 把关」被驳回,转到「子工作流 › 退回」),实例页、 + 通知说这一句,不写内部 id、不写成「已完成」; +- 定时任务的 Webhook:途经驳回、照驳回那条路成功结束的,标题和状态写途经驳回,另起一行写谁被驳回、转到哪一步。 +图和数据都是虚构的。 +""" +from __future__ import annotations + +import asyncio + +import pytest +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent, Task +from app.engine.runner import run_manager +from tests import ledger_samples as S + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}} + + +START = node("start", "input", fields=[{"name": "q"}]) +DONE = node("done", "output", "办结", fields=[{"name": "结果", "value": "通过了"}]) +BACK = node("back", "output", "退回申请人", fields=[{"name": "结果", "value": "退回:{{ nodes.gate.note }}"}]) + + +def gated(gate: dict, *, exit_to: dict | None = BACK) -> dict: + normal = "approved" if gate["data"]["config"].get("mode") == "approve" else "out" + nodes = [START, gate, DONE] + ([exit_to] if exit_to else []) + edges = [{"source": "start", "target": "gate"}, {"source": "gate", "target": "done", "sourceHandle": normal}] + if exit_to: + edges.append({"source": "gate", "target": exit_to["id"], "sourceHandle": "rejected"}) + return {"nodes": nodes, "edges": edges} + + +async def _wait(run_id: str, statuses: tuple[str, ...], timeout: float = 20.0) -> Run: + deadline = asyncio.get_event_loop().time() + timeout + while asyncio.get_event_loop().time() < deadline: + async with SessionLocal() as session: + row = await session.get(Run, run_id) + if row is not None and row.status in statuses and not run_manager.is_active(run_id): + return row + await asyncio.sleep(0.05) + raise AssertionError(f"运行 {run_id} 没有进入 {statuses}") + + +async def _parked(graph: dict) -> tuple[Run, dict]: + """发起、停在关卡上,交回运行和那条待办的中断内容。""" + run = await run_manager.start(graph=graph, input_payload={"q": "x"}) + await _wait(run.id, ("interrupted",)) + await run_manager.wait_idle(run.id) + async with SessionLocal() as session: + [task] = list((await session.execute(select(Task).where(Task.run_id == run.id))).scalars()) + from app.db.models import Approval + + approval = await session.get(Approval, task.approval_id) + return run, dict(approval.payload or {}) + + +APPROVE = node("gate", "human", "把关", mode="approve", title="可以吗") +INPUT = node("gate", "human", "补充", mode="input", title="补一下") +TOOL = node("gate", "tool", "算一下", tool="calculator", args={"expression": "1+1"}, approval="always") + + +@pytest.mark.parametrize("gate", [APPROVE, INPUT, TOOL], ids=["approve", "input", "tool"]) +async def test_the_task_says_where_a_rejection_goes(gate): + _, payload = await _parked(gated(gate)) + assert payload["reject_route"] == {"to": [{"node_id": "back", "label": "退回申请人"}]} + + +@pytest.mark.parametrize("gate", [INPUT, TOOL], ids=["input", "tool"]) +async def test_without_the_exit_the_task_says_the_run_ends(gate): + _, payload = await _parked(gated(gate, exit_to=None)) + assert payload["reject_route"] == {"ends": "run"} + + +async def test_an_approval_without_the_exit_says_this_path_stops_here(): + _, payload = await _parked(gated(APPROVE, exit_to=None)) + assert payload["reject_route"] == {"ends": "path"} + stop = node("gate", "human", "把关", mode="approve", title="可以吗", stop_on_reject=True) + _, payload = await _parked(gated(stop)) + assert payload["reject_route"] == {"ends": "run"}, "驳回即终止优先:驳回出口那条线走不到" + + +async def test_an_agent_tool_approval_has_no_route(): + """agent 自己调工具的审批不是关卡:驳回了 agent 照常往下想,中断内容不带 reject_route。""" + from app.engine.replay import reject_route + from app.engine.schema import GraphSpec + + spec = GraphSpec.model_validate(gated(node("gate", "agent", "助手", prompt="x"))) + + class Ctx: + class run: # noqa: N801 - 只要 spec 和 global_id + pass + + ctx = Ctx() + ctx.node = spec.node_map()["gate"] + ctx.run.spec = spec + assert reject_route(ctx) is None + + +# -------------------------------------------------------------------------- +# 子工作流里的关卡走了驳回出口 +# -------------------------------------------------------------------------- + + +async def _sub_rejected() -> Run: + inner = {"nodes": [node("s", "input"), node("gate", "human", "把关", mode="approve", title="可以吗"), + node("ok", "output", "通过", fields=[{"name": "r", "value": "ok"}]), + node("back", "output", "退回", fields=[{"name": "r", "value": "退回:{{ nodes.gate.note }}"}])], + "edges": [{"source": "s", "target": "gate"}, + {"source": "gate", "target": "ok", "sourceHandle": "approved"}, + {"source": "gate", "target": "back", "sourceHandle": "rejected"}]} + sub_id = await S.publish("复核子流程", graph=inner, level="draft") + graph = S.chain(node("start", "input"), node("sub", "subgraph", "复核子流程", workflow_id=sub_id, workflow_version=1), + node("out", "output", "成果", fields=[{"name": "r", "value": "{{ nodes.sub.output.r }}"}])) + run = await run_manager.start(graph=graph, input_payload={}) + await _wait(run.id, ("interrupted",)) + await run_manager.wait_idle(run.id) + await run_manager.resume(run.id, {"approved": False, "note": "不对"}, actor="张工") + return await _wait(run.id, ("cancelled", "failed", "succeeded")) + + +async def test_a_reject_exit_inside_a_subworkflow_is_named_in_the_parent(): + from app.apps.resolve import _trails, instance_state + + final = await _sub_rejected() + assert final.status == "succeeded", final.error + assert final.output == {"r": "退回:不对"} + async with SessionLocal() as session: + [ended] = list((await session.execute(select(RunEvent).where( + RunEvent.run_id == final.id, RunEvent.type == "run.finished"))).scalars()) + run = await session.get(Run, final.id) + state = instance_state(run, (await _trails(session, [run]))[run.id]) + [via] = ended.data["rejected_via"] + assert (via["node_id"], via["label"]) == ("sub/gate", "复核子流程 › 把关") + assert via["to"] == [{"node_id": "sub/back", "label": "复核子流程 › 退回"}] + # 实例页、通知(notify_run 取的就是这一句):照实说被驳回、转到哪,不写「sub/gate」,不画成已完成 + assert state == {"code": "cancelled", "text": "「复核子流程 › 把关」被驳回,转到「复核子流程 › 退回」"} + + +async def test_the_inner_task_names_the_route_with_the_subworkflow(): + inner = {"nodes": [node("s", "input"), node("gate", "human", "补充", mode="input", title="补一下"), + node("back", "output", "退回", fields=[{"name": "r", "value": "x"}]), + node("ok", "output", "通过", fields=[{"name": "r", "value": "y"}])], + "edges": [{"source": "s", "target": "gate"}, {"source": "gate", "target": "ok"}, + {"source": "gate", "target": "back", "sourceHandle": "rejected"}]} + sub_id = await S.publish("补充子流程", graph=inner, level="draft") + graph = S.chain(node("start", "input"), node("sub", "subgraph", "补充子流程", workflow_id=sub_id, workflow_version=1), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.sub.output.r }}"}])) + _, payload = await _parked(graph) + assert payload["reject_route"] == {"to": [{"node_id": "sub/back", "label": "补充子流程 › 退回"}]} + + +# -------------------------------------------------------------------------- +# 定时任务的 Webhook +# -------------------------------------------------------------------------- + + +def test_the_webhook_says_a_run_went_through_a_rejection(): + from app.scheduler.webhook import Notice, message + + via = [{"node_id": "gate", "label": "把关", "kind": "approve", "decision": "reject", "actor": "张工", "note": "不对", + "to": [{"node_id": "back", "label": "退回申请人"}]}] + title, lines, _ = message(Notice(event="finished", schedule_id="s1", schedule_name="周报", timezone="UTC", + status="succeeded", rejected_via=via)) + assert "途经驳回" in title + said = dict(lines) + assert said["状态"] == "已完成(途经驳回)" + assert said["驳回"] == "「把关」被驳回,转到「退回申请人」" + # 没途经驳回的照旧 + title, lines, _ = message(Notice(event="finished", schedule_id="s1", schedule_name="周报", timezone="UTC", + status="succeeded")) + assert title == "定时任务「周报」运行完成" and dict(lines)["状态"] == "已完成" and "驳回" not in dict(lines) + + +async def test_the_webhook_notice_reads_the_route_from_the_end_event(): + from app.scheduler.webhook import _rejected_via_of + + final = await _sub_rejected() + async with SessionLocal() as session: + run = await session.get(Run, final.id) + via = await _rejected_via_of(session, run) + assert [v["label"] for v in via] == ["复核子流程 › 把关"] diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 4c4d5056..55bbbfe6 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -5075,6 +5075,8 @@ export const DUAL_CHECK_TEXT = { /** 认不出任何指标时留的出口:驳回后节点按人工驳回失败 */ reject: '驳回并终止', emptyReject: '驳回后节点失败,本次运行终止。可以在备注中说明原因', + /** 节点接了驳回出口(中断内容的 reject_route):驳回后转到那一步 */ + emptyRejectRoute: (goes: string) => `驳回后${goes}。可以在备注中说明原因`, rejected: '已驳回,本次运行终止', }, diff --git a/frontend/src/lib/termsApp.ts b/frontend/src/lib/termsApp.ts index 9f7fb8c4..d17b8762 100644 --- a/frontend/src/lib/termsApp.ts +++ b/frontend/src/lib/termsApp.ts @@ -110,7 +110,7 @@ export const TASK_TEXT = { group: (name: string) => `组「${name}」`, }, assignmentStatus: { pending: '待回复', responded: '已回复', withdrawn: '已撤回' }, - decision: { approve: '同意', reject: '驳回', submit: '已提交', confirmed: '已生效', not_applied_resend: '重新发送', + decision: { approve: '同意', reject: '驳回', expired_reject: '超时驳回', submit: '已提交', confirmed: '已生效', not_applied_resend: '重新发送', not_applied_abandon: '放弃' } as Record, verified: '已登录核实', unverified: '自报署名', @@ -125,9 +125,13 @@ export const TASK_TEXT = { editedContent: '修改后的内容', approveHint: '批准:继续运行;驳回:转入「驳回」分支', inputHint: '提交:以你填写的内容继续运行;驳回:终止本次运行', + /** 驳回之后去哪(中断内容的 reject_route,见 REJECT_ROUTE_TEXT):带了的按它说,老待办没带的照上面两句 */ + approveHintRoute: (goes: string) => `批准:继续运行;驳回:${goes}`, + inputHintRoute: (goes: string) => `提交:以你填写的内容继续运行;驳回:${goes}`, doneApprove: '已批准,运行继续', doneReject: '已驳回,转入驳回分支', doneRejectStop: '已驳回,本次运行终止', + doneRejectRoute: (goes: string) => `已驳回,${goes}`, doneSubmit: '已提交,运行继续', doneVote: '已记下你的意见,等待其他人处理', formInvalid: (n: number) => `有 ${n} 项需要修改`, @@ -154,6 +158,34 @@ export const TASK_TEXT = { code: '代码', } +/** + * 驳回之后去哪:待办的中断内容带着 reject_route(服务端 engine/replay.reject_route,总方案 §3 的驳回出口)。 + * - to:接了驳回出口,驳回后转到这几步(名字是这次运行里的说法:子工作流里的写「子工作流 › x」); + * - ends=run:没接驳回出口(审批设了驳回即终止的也是),驳回后运行就此结束、记为被驳回; + * - ends=path:审批没接驳回出口、没设驳回即终止,这一路到审批为止,不再往下走。 + * 老待办没带的为 null,各处照原来的说法 + */ +export type RejectRoute = { to: string[] } | { ends: 'run' | 'path' } + +export function rejectRouteOf(payload: Record | null | undefined): RejectRoute | null { + const raw = payload?.reject_route + if (!raw || typeof raw !== 'object') return null + const r = raw as { to?: unknown; ends?: unknown } + if (Array.isArray(r.to)) { + const to = r.to.map((t: any) => String(t?.label || t?.node_id || '')).filter(Boolean) + return to.length ? { to } : null + } + return r.ends === 'run' || r.ends === 'path' ? { ends: r.ends } : null +} + +export const REJECT_ROUTE_TEXT = { + /** 「转到「通知申请人」」/「本次运行终止」/「这一路到此为止,不再往下走」 */ + goes: (r: RejectRoute): string => ('to' in r ? `转到${r.to.map((t) => `「${t}」`).join('、')}` + : r.ends === 'run' ? '本次运行终止' : '这一路到此为止,不再往下走'), + /** 处理过的待办「结果」那一格:驳回之后去了哪 */ + outcome: (decision: string, r: RejectRoute): string => `${decision},${REJECT_ROUTE_TEXT.goes(r)}`, +} + /** 动作确认的哈希卡(§3.8:确认时看过的就是发出去的) */ export const ACTION_CONFIRM_TEXT = { head: '确认后发送', @@ -171,6 +203,8 @@ export const ACTION_CONFIRM_TEXT = { noHash: '缺少内容哈希,无法确认发送', done: '已确认,按此内容发送', rejected: '已驳回,不发送', + /** 驳回之后去哪(reject_route):「已驳回,不发送,转到「通知申请人」」 */ + rejectedRoute: (goes: string) => `已驳回,不发送,${goes}`, } /** 核实结果未知的动作:三个选项(§2.5 A5 第 4 条) */ diff --git a/frontend/src/run/DualCheckApproval.tsx b/frontend/src/run/DualCheckApproval.tsx index 7f25f936..37173bec 100644 --- a/frontend/src/run/DualCheckApproval.tsx +++ b/frontend/src/run/DualCheckApproval.tsx @@ -10,6 +10,7 @@ import { Spinner, useToast } from '../components/ui' import { segmentState } from '../lib/evidence' import { NONE } from '../lib/format' import { CATALOG_TABLE_FIELD_LABEL, DUAL_CHECK_TEXT } from '../lib/terms' +import { REJECT_ROUTE_TEXT, TASK_TEXT, rejectRouteOf } from '../lib/termsApp' import { patchValueText } from './catalogPatch' import { RationaleDoc } from './ChallengeSummary' import type { @@ -421,6 +422,9 @@ export function DualCheckApproval({ approval, onResolved, decide }: DualCheckApp const summary = useMemo(() => summaryOf(payload.summary), [payload.summary]) const cardId = str(payload.card_artifact) ?? '' const onReject = useOnReject(approval.run_id, str(payload.node_id) ?? str(approval.node_id), payload.on_reject) + // 驳回之后去哪(中断内容的 reject_route):接了驳回出口的,驳回后转到那一步,不是「本次运行终止」 + const route = rejectRouteOf(payload) + const routed = route && 'to' in route ? REJECT_ROUTE_TEXT.goes(route) : null const [card, setCard] = useState<{ id: string; data?: Card | null } | null>(null) useEffect(() => { @@ -468,7 +472,7 @@ export function DualCheckApproval({ approval, onResolved, decide }: DualCheckApp } // 采用盲算、两边都不对时,节点设为「判为失败」就会让运行终止:提示照节点的设置说,读不到设置时不替它下结论 const rejected = Object.values(value.choices).some((c) => c !== 'a') - toast(!approved ? T.rejected : !rejected || onReject === 'degrade' ? T.done : onReject === 'fail' ? T.doneFail : T.doneUnknown, 'ok') + toast(!approved ? (routed ? TASK_TEXT.doneRejectRoute(routed) : T.rejected) : !rejected || onReject === 'degrade' ? T.done : onReject === 'fail' ? T.doneFail : T.doneUnknown, 'ok') } catch (e) { toast.error(e) } finally { @@ -514,9 +518,11 @@ export function DualCheckApproval({ approval, onResolved, decide }: DualCheckApp {noteField} -
{T.emptyReject}
+
+ {routed ? T.emptyRejectRoute(routed) : T.emptyReject} +
) ) : ( diff --git a/frontend/src/run/TaskCard.tsx b/frontend/src/run/TaskCard.tsx index 78484f99..9eb99474 100644 --- a/frontend/src/run/TaskCard.tsx +++ b/frontend/src/run/TaskCard.tsx @@ -39,7 +39,7 @@ import { ACTION_CONFIRM_TEXT, ACTION_ENV_LABEL, INQUIRY_TEXT, RECONCILE_TEXT, SIGNAL_TEXT, TASK_KIND_LABEL, TASK_STATUS_LABEL, TASK_TEXT, TOOL_TRUST_TEXT, TRIAL_TEXT, runName, } from '../lib/terms' -import { REVIEW_TASK_TEXT } from '../lib/termsApp' +import { REJECT_ROUTE_TEXT, REVIEW_TASK_TEXT, rejectRouteOf } from '../lib/termsApp' import { RECALL_TEXT } from '../lib/termsRecall' import { blueprintPath } from '../pages/blueprints/shared' import type { Approval, ApprovalReport } from '../types' @@ -102,6 +102,9 @@ export function TaskCard({ task, onResolved, compact = false, send, showOwner = const runClass: 'formal' | 'exploratory' | null = task.run_class ?? (task.app_id ? 'formal' : payload.run_class === 'formal' || payload.run_class === 'exploratory' ? payload.run_class : null) const binary = kind === 'approve' || kind === 'tool' || kind === 'code' + // 驳回之后去哪(中断内容的 reject_route):带了的照实说转到哪一步、运行终止还是这一路到此为止;老待办没带的照原来的说法 + const route = rejectRouteOf(payload) + const stops = route ? 'ends' in route && route.ends === 'run' : !binary const trustKey: string | undefined = kind === 'tool' && runClass !== 'formal' ? (typeof payload.trust_key === 'string' && payload.trust_key) || undefined : undefined const reports: ApprovalReport[] = Array.isArray(payload.reports) @@ -140,7 +143,8 @@ export function TaskCard({ task, onResolved, compact = false, send, showOwner = if (binary) void respond({ decision: 'approve' }, TASK_TEXT.doneApprove) else void respond({ decision: 'submit', values: { value: text } }, TASK_TEXT.doneSubmit) } - const reject = () => void respond({ decision: 'reject' }, binary ? TASK_TEXT.doneReject : TASK_TEXT.doneRejectStop) + const reject = () => void respond({ decision: 'reject' }, route ? TASK_TEXT.doneRejectRoute(REJECT_ROUTE_TEXT.goes(route)) + : binary ? TASK_TEXT.doneReject : TASK_TEXT.doneRejectStop) /** 「始终允许」:批准这一次,并让服务端把这个工具设成门控把关(工具页的信任档跟着变,目录重拉一次) */ const always = async () => { @@ -209,7 +213,8 @@ export function TaskCard({ task, onResolved, compact = false, send, showOwner = {kind === 'action_confirm' && ( void respond({ decision: 'approve', payload_hash: hash }, ACTION_CONFIRM_TEXT.done)} - onReject={() => void respond({ decision: 'reject' }, ACTION_CONFIRM_TEXT.rejected)} /> + onReject={() => void respond({ decision: 'reject' }, route && 'to' in route + ? ACTION_CONFIRM_TEXT.rejectedRoute(REJECT_ROUTE_TEXT.goes(route)) : ACTION_CONFIRM_TEXT.rejected)} /> )} {kind === 'reconcile' && ( {' '} - {binary ? (trial ? TRIAL_TEXT.reject : TASK_TEXT.reject) : (trial ? TRIAL_TEXT.rejectStop : TASK_TEXT.rejectStop)} + {stops ? (trial ? TRIAL_TEXT.rejectStop : TASK_TEXT.rejectStop) : (trial ? TRIAL_TEXT.reject : TASK_TEXT.reject)} {inquiring && ( @@ -272,7 +277,8 @@ export function TaskCard({ task, onResolved, compact = false, send, showOwner = )} {!compact && (
- {binary ? TASK_TEXT.approveHint : TASK_TEXT.inputHint} + {route ? (binary ? TASK_TEXT.approveHintRoute : TASK_TEXT.inputHintRoute)(REJECT_ROUTE_TEXT.goes(route)) + : binary ? TASK_TEXT.approveHint : TASK_TEXT.inputHint}
)} {/* 「始终允许」会改工具的信任档、影响之后的运行:常显同一句旁注,不只藏在悬停里 */} @@ -429,8 +435,14 @@ export function Due({ dueAt, now }: { dueAt: string | null; now: number }) { } /** 处理过的:谁、什么时候、结果(实例页上不是处理人的人看这一步的状态也用它) */ -export function Resolved({ task }: { task: Pick }) { - const decision = typeof task.outcome?.decision === 'string' ? TASK_TEXT.decision[task.outcome.decision] ?? task.outcome.decision : null +export function Resolved({ task }: { + task: Pick & { payload?: Task['payload'] } +}) { + const raw = typeof task.outcome?.decision === 'string' ? task.outcome.decision : null + const said = raw ? TASK_TEXT.decision[raw] ?? raw : null + // 驳回了的写驳回之后去了哪(中断内容的 reject_route):「驳回,转到「通知申请人」」,不只写「驳回」 + const route = raw === 'reject' || raw === 'expired_reject' ? rejectRouteOf(task.payload) : null + const decision = said && route ? REJECT_ROUTE_TEXT.outcome(said, route) : said const at = task.resolved_at ? formatDateTime(task.resolved_at) : '' return (
diff --git a/scripts/check-inbox.mjs b/scripts/check-inbox.mjs index edf06083..f9d38ee0 100644 --- a/scripts/check-inbox.mjs +++ b/scripts/check-inbox.mjs @@ -13,6 +13,8 @@ // 待办箱一行写所属实例,同标题的分得开; // - 补充输入带表单的:本地校验,服务端 422 的错误挂回字段;表单里的选人控件按这条待办的候选池取候选(使用者读不到 // 整份名录),不在池里的服务端 422(code=principal)挂回那个字段(05-已知遗留 (b)); +// - 驳回之后去哪(中断内容的 reject_route):接了驳回出口的提示、驳回后的提示、处理过的结果写「转到「x」」,子工作流里的 +// 写「子工作流 › x」、不露内部 id;补充输入接了出口的按钮写「驳回」,没接出口的工具审批写「驳回并终止」; // - 关登录时签批人一行写本机署名;会签只记下一票(resolved 为假)时提示「等待其他人处理」; // - 审核个人应用(kind=review,payload 同服务端 blueprints/personal.review_payload):写提交人(显示名)、目的、说明;没有 // 「同意」——「取副本,去发布」调 POST /api/blueprints/{id}/copy 后进副本蓝图;取过的给「打开我的副本」(只认自己取的那 @@ -204,6 +206,24 @@ const TASKS = { reviewer: { type: 'string', title: '复核人', 'x-widget': 'principal' } } }, payload: { kind: 'human_node', message: '请指定复核人' }, }, + // 驳回之后去哪(中断内容的 reject_route,服务端 engine/replay.reject_route):接了驳回出口的写转到哪一步,没接的写运行终止 + routed: { + ...base, id: 'routed', approval_id: 'ap9', kind: 'approve', title: '主管审批(接了驳回出口)', + payload: { kind: 'human_node', message: '王五报销 800 元', reject_route: { to: [{ node_id: 'tell', label: '通知申请人' }] } }, + }, + routedInput: { + ...base, id: 'routedInput', approval_id: 'ap10', kind: 'input', title: '补填用途(接了驳回出口)', + payload: { kind: 'human_node', message: '请补填用途', reject_route: { to: [{ node_id: 'sub/back', label: '复核子流程 › 退回' }] } }, + }, + stopTool: { + ...base, id: 'stopTool', approval_id: 'ap11', kind: 'tool', title: '调用工具:erp_query', + payload: { kind: 'tool_approval', tool: 'erp_query', args: { q: 1 }, reject_route: { ends: 'run' } }, + }, + rejectedDone: { + ...base, id: 'rejectedDone', approval_id: 'ap12', kind: 'approve', title: '主管审批(已驳回)', status: 'resolved', + can_respond: false, outcome: { decision: 'reject' }, resolved_by: 'zhangsan', resolved_at: ago(300_000), + payload: { kind: 'human_node', message: '赵六报销 50 元', reject_route: { to: [{ node_id: 'tell', label: '通知申请人' }] } }, + }, input: { ...base, id: 'input', approval_id: 'ap5', kind: 'input', title: '补填发票信息', form: { format: 'agentlab.form/1', type: 'object', required: ['invoice'], properties: { @@ -612,6 +632,38 @@ await section('口径召回:后台的系统通知点开去待办页(不去 } }) +await section('驳回之后去哪:提示、按钮、驳回后的提示、处理过的结果', async () => { + const state = { writes: [] } + const page = await open(state) + await page.goto(`${WEB}/apps/inbox/routed`) + await card(page).waitFor({ timeout: 15000 }) + let text = await card(page).innerText() + check('接了驳回出口的审批:提示写驳回后转到哪一步', text.includes('驳回:转到「通知申请人」'), text.slice(-80)) + check('……不再写「转入「驳回」分支」', !text.includes('转入「驳回」分支')) + await page.locator('[data-task-reject]').click() + check('驳回之后提示转到哪一步', await page.getByText('已驳回,转到「通知申请人」').waitFor({ timeout: 3000 }).then(() => true, () => false)) + check('驳回照约定交给待办接口', state.writes.some((w) => w.path === '/api/tasks/routed/respond' && w.body?.decision === 'reject')) + + await page.goto(`${WEB}/apps/inbox/routedInput`) + await card(page).waitFor({ timeout: 15000 }) + text = await card(page).innerText() + check('补充输入接了驳回出口:按钮写「驳回」,不写「驳回并终止」', (await page.locator('[data-task-reject]').innerText()).trim() === '驳回') + check('……提示写子工作流里的那一步', text.includes('驳回:转到「复核子流程 › 退回」'), text.slice(-80)) + check('……不露内部 id', !text.includes('sub/back')) + + await page.goto(`${WEB}/apps/inbox/stopTool`) + await card(page).waitFor({ timeout: 15000 }) + text = await card(page).innerText() + check('没接驳回出口的工具审批:按钮写「驳回并终止」', (await page.locator('[data-task-reject]').innerText()).trim() === '驳回并终止') + check('……提示写本次运行终止', text.includes('驳回:本次运行终止'), text.slice(-80)) + + await page.goto(`${WEB}/apps/inbox/rejectedDone`) + await card(page).waitFor({ timeout: 15000 }) + const resolved = await page.locator('[data-task-resolved]').first().innerText() + check('处理过的:结果写驳回之后去了哪', resolved.includes('结果:驳回,转到「通知申请人」'), resolved) + check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '') +}) + await section('待办箱:列表和徽标', async () => { const page = await open({ writes: [] }) await page.goto(`${WEB}/apps?tab=inbox`) From aa051b0a28a86af41177f1b9d8a5b09cf028eb26 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 19 Oct 2026 09:03:37 +0800 Subject: [PATCH 03/15] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E5=AF=B9=E6=AF=94?= =?UTF-8?q?=E9=87=8C=E5=AD=90=E5=B7=A5=E4=BD=9C=E6=B5=81=E7=9A=84=E5=8F=A3?= =?UTF-8?q?=E5=BE=84=E5=8D=A1=E6=8C=89=E5=8D=A1=E6=89=80=E5=9C=A8=E9=82=A3?= =?UTF-8?q?=E4=B8=80=E5=B1=82=E6=89=BE=E4=BA=A7=E5=87=BA=EF=BC=9A=E7=BB=84?= =?UTF-8?q?=E8=A3=85=E6=8E=A8=E6=BC=94=E7=94=A8=E7=9A=84=20Sources=20?= =?UTF-8?q?=E6=97=B6=E5=B8=A6=E4=B8=8A=E6=98=AF=E5=93=AA=E5=BC=A0=E5=8D=A1?= =?UTF-8?q?=EF=BC=8C=E8=8A=82=E7=82=B9=E4=BA=A7=E5=87=BA=E3=80=81=E8=B7=91?= =?UTF-8?q?=E5=AE=8C=E7=9A=84=E6=97=B6=E5=88=BB=E6=8C=89=20engine/lineage.?= =?UTF-8?q?in=5Fscope=20=E6=8D=A2=E6=88=90=E5=8D=A1=E9=82=A3=E4=B8=80?= =?UTF-8?q?=E5=B1=82=E7=9A=84=20id=EF=BC=88=E5=90=8C=E6=8E=A8=E6=BC=94=20a?= =?UTF-8?q?pi/whatif.=5Fsources=E3=80=81=E8=AF=81=E6=8D=AE=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=20=5FInputMasks.=5Foutput=EF=BC=89=E3=80=82=E4=BB=A5?= =?UTF-8?q?=E5=89=8D=E5=AD=90=E5=9B=BE=E9=87=8C=E7=9A=84=E5=8D=A1=E5=8F=96?= =?UTF-8?q?=E5=80=BC=E9=93=BE=E5=86=99=E7=9A=84=E6=98=AF=E5=AD=90=E5=9B=BE?= =?UTF-8?q?=E9=87=8C=E7=9A=84=20id=EF=BC=88nodes.parse=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E5=AF=B9=E6=AF=94=E6=8B=BF=E7=88=B6=E5=9B=BE=E9=87=8C=E5=90=8C?= =?UTF-8?q?=E5=90=8D=E7=9A=84=E8=8A=82=E7=82=B9=E5=BD=93=E5=AE=83=E7=9A=84?= =?UTF-8?q?=E4=BA=A7=E5=87=BA=EF=BC=8C=E5=AE=B9=E5=99=A8=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E8=AE=A4=E5=88=B0=E7=88=B6=E5=9B=BE=E9=82=A3=E6=AC=A1=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E4=B8=8A=EF=BC=9B=E5=8A=A0=E4=B8=80=E6=9D=A1=E5=AD=90?= =?UTF-8?q?=E5=9B=BE=E5=8D=A1=E7=9A=84=E7=9C=9F=E8=B7=91=E5=AF=B9=E6=AF=94?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/compare.py | 12 +++- backend/app/engine/compare.py | 9 +-- backend/tests/fixtures/compare_runs.py | 2 +- backend/tests/test_compare_api.py | 76 ++++++++++++++++++++++++++ 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/backend/app/api/compare.py b/backend/app/api/compare.py index 16fbf75f..aa82f7d9 100644 --- a/backend/app/api/compare.py +++ b/backend/app/api/compare.py @@ -184,9 +184,15 @@ async def _side(run: Run, sealed: _Sealed, masks: _Masks, kinds: dict[str, Any]) for artifact, info in sealed.queries.items()} returned = _returned(sealed) - def sources(started: int) -> recompute.Sources: - return recompute.Sources(load=loader, queries=tuple(sealed.queries), outputs=_outputs_before(sealed, started), - masked=masked, returned=returned, finished=_finished_before(sealed, started)) + def sources(started: int, card_node: str = "") -> recompute.Sources: + # 节点产出、跑完的时刻按口径卡所在那一层的 id 交进去(engine/lineage.in_scope,同推演 api/whatif._sources):子工作流 + # 里的卡(「子工作流节点 id/卡」)取值链写的是子图里的 id,只看同一张子图里的节点,父图里同名的不算 + from app.engine.lineage import in_scope + + return recompute.Sources(load=loader, queries=tuple(sealed.queries), + outputs=in_scope(_outputs_before(sealed, started), card_node), + masked=masked, returned=returned, + finished=in_scope(_finished_before(sealed, started), card_node)) return engine.Side(run=run_ref(run), graph=sealed.graph, events=sealed.events, cards=cards, load=loader, masked=masked, sources=sources, dialects=kinds, inputs=dict(run.input or {})) diff --git a/backend/app/engine/compare.py b/backend/app/engine/compare.py index 49839bf1..bbc278ae 100644 --- a/backend/app/engine/compare.py +++ b/backend/app/engine/compare.py @@ -183,8 +183,9 @@ class Side: 没有的,就是读不出来或者被改过 - load:工件 id → 内容,只认封存范围内的;文件不在返回 None,内容和哈希对不上抛 ValueError - masked:查询快照 → 要遮的列(小写):查询当时记下的加数据源现在设的 - - sources:口径卡那次执行开始的事件序号 → 推演重算还原输入要用的 Sources(节点产出、跑完的时刻都按那一刻)。 - 不给就按 load、masked 拼一个最小的:nodes.* 这类容器输入还原不出来 + - sources:(口径卡那次执行开始的事件序号, 卡在这次运行里的节点 id) → 推演重算还原输入要用的 Sources(节点产出、跑完的 + 时刻都按那一刻,按卡所在那一层的 id 记:子工作流里的卡只看同一张子图里的节点)。不给就按 load、masked 拼一个最小的: + nodes.* 这类容器输入还原不出来 - dialects:数据源名 → 类型(sqlite / postgres …),规范化 SQL 时按它的方言解析 - inputs:运行输入(Run.input) """ @@ -195,7 +196,7 @@ class Side: cards: list[tuple[str, str, dict[str, Any]]] = field(default_factory=list) load: Callable[[str], Any] = lambda _: None masked: Mapping[str, frozenset[str]] = field(default_factory=dict) - sources: Callable[[int], recompute.Sources] | None = None + sources: Callable[[int, str], recompute.Sources] | None = None dialects: Mapping[str, str | None] = field(default_factory=dict) inputs: dict[str, Any] = field(default_factory=dict) @@ -454,7 +455,7 @@ def _plan_card(side: Side, card: _Card, queries: list[_Query]) -> None: """按推演重算的规则还原卡上每个指标的输入:落在哪份快照上、是不是遮罩列、容器的完整值。""" if card.content is None: return - sources = side.sources(card.started) if side.sources is not None else recompute.Sources( + sources = side.sources(card.started, card.node_id) if side.sources is not None else recompute.Sources( load=side.load, queries=tuple(dict.fromkeys(q.artifact for q in queries)), masked=side.masked) store = recompute._Store(sources) card.sources = sources diff --git a/backend/tests/fixtures/compare_runs.py b/backend/tests/fixtures/compare_runs.py index 9e0d3944..455f28d5 100644 --- a/backend/tests/fixtures/compare_runs.py +++ b/backend/tests/fixtures/compare_runs.py @@ -187,7 +187,7 @@ class Fake: return artifact def side(self): - def sources(started): + def sources(started, card_node=""): outputs = {} for seq, etype, nid, data in self.events: if seq < started and etype == "node.finished" and data.get("artifact"): diff --git a/backend/tests/test_compare_api.py b/backend/tests/test_compare_api.py index 541f87b7..41fa1b8f 100644 --- a/backend/tests/test_compare_api.py +++ b/backend/tests/test_compare_api.py @@ -476,3 +476,79 @@ async def test_real_runs_split_data_and_parameter_changes(client, engine_up, ord body = await get(client, f"/api/runs/{third}/compare/base") assert (body["base_run_id"], body["reason"]) == (second, "same_workflow") json.dumps(body, allow_nan=False) + + +# -------------------------------------------------------------------------- +# 子工作流里的口径卡:还原输入按卡所在那一层找产出 +# -------------------------------------------------------------------------- + + +def sub_card_graph(): + """子工作流:取数 → 原样解析 → 口径卡(东区金额读单元格;区域数读解析出来的整份结果的行,要从解析节点当时的产出还原)。""" + return chain( + node("s", "input", "开始"), + node("fetch", "tool", "区域取数", tool=f"db_query__{E2E_SOURCE}", args={"sql": E2E_SQL.replace( + "{{ input.start }}", "2026-08-31").replace("{{ input.end }}", "2026-09-06")}), + node("parse", "transform", "原样解析", mode="json", template="{{ nodes.fetch }}", assign_to="rows"), + node("card", "metrics", "子口径卡", caliber="周报口径", caliber_version="v1", metrics=[ + {"id": "east", "name": "东区金额", "unit": "元", "decimals": 1, "format": "thousands", + "expression": "cell(nodes.fetch, 0, 'gmv')"}, + {"id": "regions", "name": "区域数", "unit": "个", "decimals": 0, "format": "plain", + "expression": "len(nodes.parse.rows)"}]), + node("o", "output", "出口", fields=[{"name": "指标", "value": "{{ nodes.card.text }}"}]), + ) + + +async def test_cards_inside_a_subworkflow_restore_inputs_from_their_own_layer(client, engine_up, orders): + """子工作流里的卡(这次运行里的 id「sub/card」)取值链写的是子图里的 id(nodes.parse):对比还原容器输入时要找「sub/parse」 + 的产出,不是父图里的 parse。父图里另有同名的 fetch、parse(查的是另一条 SQL),按层找才不会取错。""" + from tests import ledger_samples as S + + path, source_id = orders + sub_id = await S.publish("区域子流程", graph=sub_card_graph(), level="draft") + workflow = await new_workflow("子流程周报") + + def graph(): + return chain( + node("start", "input", "开始"), + # 父图里同名的取数、解析节点:一行,和子图里那份三行的结果不一样 + node("fetch", "tool", "父图取数", tool=f"db_query__{E2E_SOURCE}", + args={"sql": "SELECT COUNT(*) AS n FROM orders"}), + node("parse", "transform", "父图解析", mode="json", template="{{ nodes.fetch }}"), + node("sub", "subgraph", "区域子流程", workflow_id=sub_id, workflow_version=1), + node("out", "output", "成果", fields=[{"name": "指标", "value": "{{ nodes.sub.output.指标 }}"}]), + ) + + async def finished() -> str: + run = await run_manager.start(graph=graph(), input_payload={}, workflow_id=workflow, workflow_name="子流程周报") + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed") and row.manifest_seq is not None: + assert row.status == "succeeded", row.error + return run.id + raise AssertionError(f"运行 {run.id} 没有结束") + + first = await finished() + db = sqlite3.connect(path) + db.execute("UPDATE orders SET amount = 1500.5 WHERE day = '2026-09-01'") + db.commit() + db.close() + await engines.invalidate(source_id) + second = await finished() + + body = await get(client, f"/api/runs/{second}/compare?base={first}") + metrics = {m["metric_id"] if "metric_id" in m else m["id"]: m for m in body["metrics"]} + east, regions = metrics["east"], metrics["regions"] + assert (east["classification"], east["base"], east["target"]) == ("data_only", 1200.5, 1500.5) + # 区域数:从子图里那次取数的产出还原(三行),两次一样 + assert (regions["base"], regions["target"]) == (3, 3) + assert regions["classification"] == "unchanged", regions + problems = [i.get("problem") for i in regions.get("inputs") or [] if i.get("problem")] + assert not problems, problems + # 区域数的那份结果顺着取值链认到子图里那次取数(两次之间改了数,快照不同),不是父图里同名的 fetch(两次一样) + [cell] = east["inputs"] + [rows] = regions["inputs"] + assert (rows["base_artifact"], rows["target_artifact"]) == (cell["base_artifact"], cell["target_artifact"]) + assert rows["base_artifact"] != rows["target_artifact"] From 04d80472f96f8ca66eb14e1d94769b520d13b2c9 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 19 Oct 2026 09:08:45 +0800 Subject: [PATCH 04/15] =?UTF-8?q?=E5=AD=90=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=E9=87=8C=E7=9A=84=E5=8F=8D=E6=96=B9=E5=AE=A1=E7=A8=BF=E6=8C=89?= =?UTF-8?q?=E5=85=A8=E5=B1=80=20id=20=E8=AE=B0=E5=AE=83=E5=AE=A1=E7=9A=84?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=EF=BC=9A=E5=AE=A1=E7=A8=BF=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=92=8C=E5=B7=A5=E4=BB=B6=E4=B8=8A=E7=9A=84=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E5=86=99=E8=BF=99=E6=AC=A1=E8=BF=90=E8=A1=8C=E9=87=8C=E7=9A=84?= =?UTF-8?q?=20id=EF=BC=88=E3=80=8Csub/write=E3=80=8D=EF=BC=8C=E5=90=8C=20r?= =?UTF-8?q?eport.checked=E3=80=81=E6=8A=A5=E5=91=8A=E6=96=87=E6=A1=A3?= =?UTF-8?q?=EF=BC=8C=E7=AC=AC=201=20=E6=B3=A2=20B1=20=E4=BF=AE=E8=BF=87?= =?UTF-8?q?=E6=8A=A5=E5=91=8A=E8=8A=82=E7=82=B9=E5=90=8C=E6=A0=B7=E7=9A=84?= =?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=89=EF=BC=8C=E8=AF=81=E6=8D=AE=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E8=BF=9E=E5=AD=90=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=87=8C?= =?UTF-8?q?=E7=9A=84=E5=AE=A1=E7=A8=BF=E4=B8=80=E8=B5=B7=E8=AE=A4=EF=BC=88?= =?UTF-8?q?=5FSealed.nodes=5Fof=20nested=EF=BC=89=EF=BC=8C=E7=82=B9?= =?UTF-8?q?=E5=BC=80=E5=AD=90=E5=9B=BE=E9=87=8C=E9=82=A3=E4=BB=BD=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E7=9A=84=E5=8F=A5=E5=AD=90=E8=AE=A4=E5=BE=97=E5=87=BA?= =?UTF-8?q?=E5=AE=A1=E5=AE=83=E7=9A=84=E9=82=A3=E6=AC=A1=E5=AE=A1=E7=A8=BF?= =?UTF-8?q?=EF=BC=9B=E5=AE=A1=E6=89=B9=E5=8D=A1=E4=B8=8A=E7=9A=84=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E6=8C=89=E5=85=A8=E5=B1=80=20id=20=E8=AE=B0=EF=BC=8C?= =?UTF-8?q?=E6=89=93=E5=BC=80=E8=AF=81=E6=8D=AE=E9=9D=A2=E6=9D=BF=E3=80=81?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E8=B4=A8=E8=AF=A2=E5=AF=B9=E5=BE=97=E4=B8=8A?= =?UTF-8?q?=EF=BC=9B=E5=8D=95=E5=8F=A5=E9=9D=A2=E6=9D=BF=E7=9A=84=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E5=90=8D=E5=86=99=E3=80=8C=E5=AD=90=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=20=E2=80=BA=20=E6=8A=A5=E5=91=8A=E3=80=8D=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=99=E5=86=85=E9=83=A8=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/evidence.py | 16 +++++--- backend/app/engine/nodes/challenge.py | 9 +++-- backend/app/engine/nodes/human.py | 3 +- backend/tests/test_challenge_node.py | 53 +++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/backend/app/api/evidence.py b/backend/app/api/evidence.py index 82f491a0..fb232c6e 100644 --- a/backend/app/api/evidence.py +++ b/backend/app/api/evidence.py @@ -223,9 +223,13 @@ class _Sealed: 当「已封存」展示——只看 sealed 的消费方会把一条断掉的封存链画成绿的。""" return self.sealed and self.seal["ok"] is True - def nodes_of(self, node_type: str) -> list[str]: - return [str(n.get("id")) for n in self.graph.get("nodes") or [] - if isinstance(n, dict) and n.get("type") == node_type] + def nodes_of(self, node_type: str, *, nested: bool = False) -> list[str]: + """这次运行的图上这一类的节点;nested 为真时连子工作流里跑过的一起给(「子工作流节点 id/…」,类型照 node.started 记的)。""" + out = [str(n.get("id")) for n in self.graph.get("nodes") or [] + if isinstance(n, dict) and n.get("type") == node_type] + if nested: + out += [nid for nid, kind in self.types.items() if "/" in nid and kind == node_type] + return out def payload_of(self, node_ids: list[str]) -> dict[str, Any] | None: """这几个节点里最后跑完的那个的产出,从封存的事件指向的工件里取。""" @@ -1764,7 +1768,8 @@ def _reviews(sealed: _Sealed, report: _Report 审稿记录和计数,按它认(只认审稿记录存上了的),产出拼成 {challenge_artifact, counts, on_refuted: fail}。运行因此 失败了,被推翻的是哪几句、反方凭什么,证据面板里照样看得到。 """ - reviewers = set(sealed.nodes_of("challenge")) + # 子工作流里的反方审稿也认(「sub/chal」):它审的是子工作流里那份报告(审稿记录按全局 id 记,「sub/write」) + reviewers = set(sealed.nodes_of("challenge", nested=True)) if not reviewers: return spec: GraphSpec | None = None @@ -1843,7 +1848,8 @@ def _challenge_of(sealed: _Sealed, report: _Report, unit_id: str) -> dict[str, A unreviewed = output.get("unreviewed") found = {"verdict": verdict, "node_id": node_id, "artifact": artifact, "on_refuted": str(output.get("on_refuted") or "degrade"), - "report_label": writer.title if writer else report.node_id, + # 子工作流里的报告(「sub/write」)不在运行的图上:写「子工作流 › 报告」,不写内部 id + "report_label": writer.title if writer else _node_label(sealed, report.node_id), "counts": _challenge_counts(output.get("counts"), claims), **({"unreviewed": unreviewed} if isinstance(unreviewed, int) and not isinstance(unreviewed, bool) and unreviewed >= 0 else {})} diff --git a/backend/app/engine/nodes/challenge.py b/backend/app/engine/nodes/challenge.py index 4b8f21ce..f44c98ad 100644 --- a/backend/app/engine/nodes/challenge.py +++ b/backend/app/engine/nodes/challenge.py @@ -811,8 +811,10 @@ class _Review: # 没能反查的句数:没派出去、派出去却没查成的(unreviewed 里各有原因),运行时长上限将至没开始的,加上触顶时没能给出 # 结论的。都记为无法判断,可「无法判断 2 句」既可能是查了、数据不够,也可能是根本没查:审批卡要分开说 missed = len(unreviewed) + overdue + sum(skipped for _, skipped in hit.values()) - artifact = await self._store({"report": self.report_id, "doc_artifact": self.doc_artifact, "claims": claims, - "model": model}) + # 报告按这次运行里的 id 记(子工作流里的写「子工作流节点 id/报告」,同 report.checked、报告文档上的 node_id): + # 证据面板、可验证报告按它认审的是哪份报告 + artifact = await self._store({"report": self.ctx.run.global_id(self.report_id), "doc_artifact": self.doc_artifact, + "claims": claims, "model": model}) for limit, (touched, skipped) in hit.items(): words = self._limit_words(limit) message = f"已达上限({words}),{skipped} 句未反查" if limit == "daily_max_usd" else \ @@ -872,7 +874,8 @@ class _Review: async def _store(self, record: dict[str, Any]) -> str | None: try: return await artifact_store.put_json(record, kind="challenge", run_id=self.ctx.run.run_id, - node_id=self.ctx.node.id, meta={"report": self.report_id}) + node_id=self.ctx.run.global_id(self.ctx.node.id), + meta={"report": self.ctx.run.global_id(self.report_id)}) except Exception as e: # noqa: BLE001 - 记录写失败不毁掉运行:结论照常处置,只是理由和查询点不开 self.ctx.emit(EventType.LOG, level="warn", code="evidence_store_failed", message=f"反方审稿记录保存失败({describe_exception(e)}),审稿的理由和查询将无法查看") diff --git a/backend/app/engine/nodes/human.py b/backend/app/engine/nodes/human.py index b112a39f..cf2639ee 100644 --- a/backend/app/engine/nodes/human.py +++ b/backend/app/engine/nodes/human.py @@ -227,7 +227,8 @@ def _reviewed_reports(state: GraphState, ctx: NodeContext) -> list[dict[str, Any or not isinstance(result.get("stats"), dict): continue item: dict[str, Any] = { - "node_id": rid, "label": node.title, + # 按这次运行里的 id 记(子工作流里的写「子工作流节点 id/报告」):审批卡据此打开证据面板(?report=) + "node_id": ctx.run.global_id(rid), "label": node.title, "stats": {k: v for k, v in result["stats"].items() if isinstance(v, int) and not isinstance(v, bool)}, "claims": result.get("claims") or "off", } diff --git a/backend/tests/test_challenge_node.py b/backend/tests/test_challenge_node.py index 896b1dac..1b8768ea 100644 --- a/backend/tests/test_challenge_node.py +++ b/backend/tests/test_challenge_node.py @@ -1431,3 +1431,56 @@ def test_values_the_agent_found_are_masked_in_the_rationale_when_the_report_fill rationale=doc, usage={}, masked_values=seen.pooled()) masked = challenge_mod._masked_rationale(outcome, secrets, given=[]) assert masked.result["rationale"] == "号码 已遮罩 的客户在华南。" + + +# -------------------------------------------------------------------------- +# 子工作流里的反方审稿 +# -------------------------------------------------------------------------- + + +async def _in_subworkflow(spec: dict) -> dict: + """把 spec 发布成子工作流,父图:开始 → 子工作流 → 成果。""" + from tests import ledger_samples as S + + sub_id = await S.publish("周报子流程", graph=spec, level="draft") + return chain(node("start", "input"), node("sub", "subgraph", "周报子流程", workflow_id=sub_id, workflow_version=1), + node("top", "output", fields=[{"name": "周报", "value": "{{ nodes.sub.output.周报 }}"}])) + + +async def test_a_challenge_inside_a_subworkflow_names_the_report_by_its_run_id(monkeypatch, shop): + """子工作流里的报告在父运行里按全局 id(「sub/write」)记(report.checked、报告文档):审稿记录也按它记,证据面板点开 + 子图里那份报告的句子时认得出审它的那次审稿,不再因为记的是子图里的 id(write)对不上、什么都不显示。""" + from httpx import ASGITransport, AsyncClient + + from app.main import app + + Models(monkeypatch, shop) + row = await finish(await _in_subworkflow(graph(shop, contract=False)), run_class="exploratory") + assert row.status == "succeeded", row.error + out = await output_of(row.id, "sub/chal") + record = artifact_store.load(out["challenge_artifact"]) + [checked] = await events(row.id, "report.checked", "sub/write") + assert (record["report"], record["doc_artifact"]) == ("sub/write", checked.data["doc_artifact"]) + doc = artifact_store.load(checked.data["doc_artifact"]) + south = unit_of(doc, SOUTH) + seg = next(s for _, u in iter_units(doc) if u["id"] == south + for s in u.get("segments") or [] if s.get("kind") != "structural") + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + res = await client.get(f"/api/runs/{row.id}/evidence/segments/{seg['id']}?report=sub/write") + assert res.status_code == 200, res.text + challenge = res.json()["unit"].get("challenge") or {} + assert (challenge.get("verdict"), challenge.get("node_id")) == ("refuted", "sub/chal") + + +async def test_an_approval_inside_a_subworkflow_points_at_the_report_by_its_run_id(monkeypatch, shop): + """子工作流里「报告 → 反方审稿 → 人工审批」:审批卡上的报告按这次运行里的 id 记(「sub/write」),审批卡据此打开证据面板, + 反方审稿的概况照样带上。""" + Models(monkeypatch, shop) + row = await finish(await _in_subworkflow(gated(shop)), run_class="exploratory", + statuses=("interrupted", "failed", "succeeded")) + assert row.status == "interrupted", row.error + async with SessionLocal() as session: + approval = (await session.execute(select(Approval).where(Approval.run_id == row.id))).scalar_one() + [report] = approval.payload["reports"] + assert report["node_id"] == "sub/write" + assert report["challenge"]["counts"]["refuted"] == 1 From d1ad670fb3169b23fc411d87614629795f6e54cc Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 19 Oct 2026 09:10:27 +0800 Subject: [PATCH 05/15] =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E8=A7=84=E5=88=99?= =?UTF-8?q?=E5=AF=B9=E5=AD=90=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=87=8C=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E7=9A=84=E9=82=A3=E4=B8=80=E6=AD=A5=E5=8F=AA=E6=8A=A5?= =?UTF-8?q?=E4=B8=80=E6=9D=A1=EF=BC=9A=E5=AD=90=E5=9B=BE=E9=87=8C=E9=82=A3?= =?UTF-8?q?=E4=B8=80=E6=AD=A5=E3=80=81=E5=AD=90=E5=B7=A5=E4=BD=9C=E6=B5=81?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E5=90=84=E5=8F=91=E4=B8=80=E6=9D=A1=E5=8E=9F?= =?UTF-8?q?=E8=AF=9D=E7=9B=B8=E5=90=8C=E7=9A=84=20node.failed=EF=BC=8C?= =?UTF-8?q?=E4=BB=A5=E5=89=8D=E5=90=88=E6=88=90=E4=B8=A4=E6=9D=A1=E3=80=8C?= =?UTF-8?q?=E6=89=A7=E8=A1=8C=E5=A4=B1=E8=B4=A5=E3=80=8D=EF=BC=88=E9=A9=B3?= =?UTF-8?q?=E5=9B=9E=E5=90=8C=E7=90=86=E4=B8=A4=E6=9D=A1=E3=80=8C=E8=A2=AB?= =?UTF-8?q?=E9=A9=B3=E5=9B=9E=E3=80=8D=EF=BC=89=EF=BC=8C=E9=97=AE=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E7=9A=84=E5=A4=8D=E6=A0=B8=E6=8F=90=E7=A4=BA=E9=87=8C?= =?UTF-8?q?=E5=90=8C=E4=B8=80=E4=BB=B6=E4=BA=8B=E5=86=99=E4=B8=A4=E9=81=8D?= =?UTF-8?q?=E3=80=82=E7=8E=B0=E5=9C=A8=E4=BF=9D=E7=95=99=E6=9B=B4=E5=85=B7?= =?UTF-8?q?=E4=BD=93=E7=9A=84=E9=82=A3=E4=B8=80=E6=AD=A5=EF=BC=8C=E5=90=8D?= =?UTF-8?q?=E5=AD=97=E4=B8=80=E5=B1=82=E5=B1=82=E6=8E=A5=E4=B8=8A=E5=86=99?= =?UTF-8?q?=E6=B8=85=E5=9C=A8=E5=93=AA=E4=B8=AA=E5=AD=90=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E9=87=8C=EF=BC=88=E3=80=8C=E5=A4=8D=E6=A0=B8=E5=AD=90?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=20=E2=80=BA=20=E7=AE=97=E4=B8=80=E4=B8=8B?= =?UTF-8?q?=E3=80=8D=EF=BC=89=EF=BC=8C=E4=B8=8D=E5=86=99=E5=86=85=E9=83=A8?= =?UTF-8?q?=20id=EF=BC=9B=E5=AD=90=E5=B7=A5=E4=BD=9C=E6=B5=81=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E8=87=AA=E5=B7=B1=E5=A4=B1=E8=B4=A5=E3=80=81=E5=8E=9F?= =?UTF-8?q?=E8=AF=9D=E4=B8=8D=E5=90=8C=E7=9A=84=E7=85=A7=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/review.py | 22 +++++- backend/tests/test_review_subgraph.py | 105 ++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_review_subgraph.py diff --git a/backend/app/engine/review.py b/backend/app/engine/review.py index 98f349ee..26b10939 100644 --- a/backend/app/engine/review.py +++ b/backend/app/engine/review.py @@ -206,6 +206,8 @@ def scan(events: Iterable[Any], output: Mapping[str, Any] | None = None) -> list elif etype == EventType.TOOL_END and tool and tool in failed_at: recovered.add(tool) + #: 报过的节点失败 (节点 id, 原话):子工作流节点跟着子图里那一步失败时认得出是同一件事 + failures: list[tuple[str, str]] = [] for e in rows: etype = str(_event_field(e, "type", "")) data = _event_field(e, "data") or {} @@ -224,11 +226,18 @@ def scan(events: Iterable[Any], output: Mapping[str, Any] | None = None) -> list # 失败」;也不进 RETRYABLE——重跑只会把同一件事再问一遍人。答案没走完是缺口,记 DEGRADED(没有成果时 # empty_output 另按 BROKEN 说) err = str(data.get("error") or "").strip()[:200] - add("node_rejected", f"节点「{titles.get(node_id) or node_id}」被驳回:{err}", DEGRADED) + if any(inner.startswith(f"{node_id}/") and said == err for inner, said in failures): + continue # 子工作流里那一步被驳回、子工作流节点跟着收尾:同一件事,只报那一步(同下面的执行失败) + failures.append((str(node_id), err)) + add("node_rejected", f"节点「{_step_name(str(node_id), titles)}」被驳回:{err}", DEGRADED) elif etype == EventType.NODE_FAILED: err = str(data.get("error") or "").strip()[:200] - add("node_failed", f"节点「{titles.get(node_id) or node_id}」执行失败:{err}", BROKEN) + if any(inner.startswith(f"{node_id}/") and said == err for inner, said in failures): + # 子工作流里那一步失败了,子工作流节点(「sub」)跟着失败、原话相同:同一件事,只报更具体的那一步(上面已报) + continue + failures.append((str(node_id), err)) + add("node_failed", f"节点「{_step_name(str(node_id), titles)}」执行失败:{err}", BROKEN) elif etype == EventType.RUN_FAILED: err = str(data.get("error") or "").strip()[:200] @@ -264,6 +273,15 @@ def scan(events: Iterable[Any], output: Mapping[str, Any] | None = None) -> list return signals +def _step_name(node_id: str, titles: Mapping[str, str]) -> str: + """失败的那一步给人看的名字。子工作流里的(「子工作流节点 id/…」)一层层接上 node.started 记的名字:「复核子流程 › 算一下」, + 不写内部 id;没记名字的那一层照写 id 的那一段。""" + if "/" not in node_id: + return titles.get(node_id) or node_id + parts = node_id.split("/") + return " › ".join(titles.get("/".join(parts[:i + 1])) or parts[i] for i in range(len(parts))) + + def worst(signals: Iterable[Signal]) -> str: """一组信号里最重的那档。没有信号时返回空串。""" ranked = sorted(signals, key=lambda s: _SEVERITY_RANK.get(s.severity, 1), reverse=True) diff --git a/backend/tests/test_review_subgraph.py b/backend/tests/test_review_subgraph.py new file mode 100644 index 00000000..6ea79e4a --- /dev/null +++ b/backend/tests/test_review_subgraph.py @@ -0,0 +1,105 @@ +"""复核规则(engine/review.scan)对子工作流里失败的那一步只报一条:子图里那一步一条 node.failed、子工作流节点又一条,原话 +相同,以前合成两条「执行失败」,问数据页的复核提示里同一件事写两遍。保留更具体的那一步,名字写清在哪个子工作流里 +(「子工作流 › 那一步」,不写内部 id)。图和数据都是虚构的。 +""" +from __future__ import annotations + +import asyncio + +import pytest +from sqlalchemy import select + +from app.core.events import EventType +from app.db.base import SessionLocal +from app.db.models import Run, RunEvent +from app.engine import review as rv +from app.engine.runner import run_manager +from tests import ledger_samples as S + +OK = {"结果": "x"} + + +def ev(etype, **data): + return {"type": str(etype), "node_id": data.pop("node_id", ""), "data": data} + + +def test_a_failure_inside_a_subworkflow_is_reported_once_with_where_it_happened(): + signals = rv.scan([ + ev(EventType.NODE_STARTED, node_id="sub", label="复核子流程"), + ev(EventType.NODE_STARTED, node_id="sub/calc", label="算一下"), + ev(EventType.NODE_FAILED, node_id="sub/calc", error="表达式写错了"), + ev(EventType.NODE_FAILED, node_id="sub", error="表达式写错了"), + ], OK) + [signal] = [s for s in signals if s.kind == "node_failed"] + assert signal.detail == "节点「复核子流程 › 算一下」执行失败:表达式写错了" + assert "sub/" not in signal.detail and signal.severity == rv.BROKEN + + +def test_nested_two_levels_and_different_errors_stay_apart(): + signals = rv.scan([ + ev(EventType.NODE_STARTED, node_id="a", label="外层"), + ev(EventType.NODE_STARTED, node_id="a/b", label="内层"), + ev(EventType.NODE_STARTED, node_id="a/b/c", label="取数"), + ev(EventType.NODE_FAILED, node_id="a/b/c", error="连不上"), + ev(EventType.NODE_FAILED, node_id="a/b", error="连不上"), + ev(EventType.NODE_FAILED, node_id="a", error="连不上"), + # 父图里另一个节点原话不同的失败照报 + ev(EventType.NODE_STARTED, node_id="other", label="另一步"), + ev(EventType.NODE_FAILED, node_id="other", error="超时"), + ], OK) + assert [s.detail for s in signals if s.kind == "node_failed"] == [ + "节点「外层 › 内层 › 取数」执行失败:连不上", "节点「另一步」执行失败:超时"] + + +def test_a_rejection_inside_a_subworkflow_is_reported_once_too(): + signals = rv.scan([ + ev(EventType.NODE_STARTED, node_id="sub", label="复核子流程"), + ev(EventType.NODE_STARTED, node_id="sub/gate", label="把关"), + ev(EventType.NODE_FAILED, node_id="sub/gate", error="人工驳回:不对", reason="rejected"), + ev(EventType.NODE_FAILED, node_id="sub", error="人工驳回:不对", reason="rejected"), + ], OK) + assert [s.detail for s in signals if s.kind == "node_rejected"] == ["节点「复核子流程 › 把关」被驳回:人工驳回:不对"] + + +def test_a_subworkflow_failing_on_its_own_still_reports(): + """子工作流节点自己失败(没选工作流、版本不存在):子图里没有哪一步失败,照报这一条。""" + signals = rv.scan([ + ev(EventType.NODE_STARTED, node_id="sub", label="复核子流程"), + ev(EventType.NODE_FAILED, node_id="sub", error="「子工作流」节点尚未选择要嵌套的工作流"), + ], OK) + assert [s.detail for s in signals if s.kind == "node_failed"] == [ + "节点「复核子流程」执行失败:「子工作流」节点尚未选择要嵌套的工作流"] + + +@pytest.fixture +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}} + + +async def test_a_real_run_failing_inside_a_subworkflow_gives_one_signal(engine_up): + inner = S.chain(node("s", "input"), node("calc", "transform", "算一下", mode="expression", expression="1 / 0"), + node("o", "output", fields=[{"name": "r", "value": "{{ nodes.calc.text }}"}])) + sub_id = await S.publish("复核子流程", graph=inner, level="draft") + graph = S.chain(node("start", "input"), node("sub", "subgraph", "复核子流程", workflow_id=sub_id, workflow_version=1), + node("out", "output", fields=[{"name": "r", "value": "{{ nodes.sub.output.r }}"}])) + run = await run_manager.start(graph=graph, input_payload={}) + for _ in range(400): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("failed", "succeeded") and not run_manager.is_active(run.id): + break + assert row.status == "failed" + async with SessionLocal() as session: + rows = list((await session.execute(select(RunEvent).where(RunEvent.run_id == run.id) + .order_by(RunEvent.seq))).scalars()) + events = [{"type": r.type, "node_id": r.node_id, "data": r.data} for r in rows] + failed = [s for s in rv.scan(events, {}) if s.kind == "node_failed"] + assert len(failed) == 1, [s.detail for s in failed] + assert failed[0].detail.startswith("节点「复核子流程 › 算一下」执行失败:") From 8ba0b270971af98e3d43d2ed6cc014e4beaf224f Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 19 Oct 2026 09:16:14 +0800 Subject: [PATCH 06/15] =?UTF-8?q?=E5=90=8C=E4=B8=80=E4=B8=AA=E5=AD=90?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=E8=8A=82=E7=82=B9=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E5=A4=9A=E6=AC=A1=E6=97=B6=E5=AD=90=E5=9B=BE=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E5=88=86=E5=BE=97=E5=BC=80=EF=BC=9A=E5=AD=90?= =?UTF-8?q?=E5=9B=BE=E8=BF=99=E4=B8=80=E6=AC=A1=E8=B7=91=E8=BF=87=E7=9A=84?= =?UTF-8?q?=E6=AF=8F=E4=B8=80=E6=AD=A5=E4=BA=A4=E5=9B=9E=E7=88=B6=E5=9B=BE?= =?UTF-8?q?=E7=9A=84=20trail=EF=BC=88=E8=8A=82=E7=82=B9=20id=20=E6=8E=A5?= =?UTF-8?q?=E4=B8=8A=E5=89=8D=E7=BC=80=EF=BC=89=EF=BC=8C=E4=B8=8B=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E6=89=A7=E8=A1=8C=E4=BB=8E=E9=82=A3=E9=87=8C=E6=8E=A5?= =?UTF-8?q?=E7=9D=80=E6=95=B0=E7=AC=AC=E5=87=A0=E6=AC=A1=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=EF=BC=88engine/evidence.next=5Fexec=EF=BC=89=EF=BC=8C=E5=8F=B0?= =?UTF-8?q?=E8=B4=A6=E3=80=81=E8=AF=81=E6=8D=AE=E9=9D=A2=E6=9D=BF=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=87=A0=E6=9D=A1=E9=83=BD=E8=AE=B0=E7=AC=AC=201=20?= =?UTF-8?q?=E6=AC=A1=EF=BC=9B=E5=86=99=E5=A4=96=E9=83=A8=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E7=9A=84=E5=B9=82=E7=AD=89=E9=94=AE=E6=8C=89=E8=BF=99=E6=AC=A1?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E9=87=8C=E7=9A=84=E8=8A=82=E7=82=B9=20id=20?= =?UTF-8?q?=E7=AE=97=EF=BC=88=E3=80=8Csub/pay=E3=80=8D=EF=BC=89=E3=80=82?= =?UTF-8?q?=E4=BB=A5=E5=89=8D=E5=BE=AA=E7=8E=AF=E9=87=8C=E7=AC=AC=E4=BA=8C?= =?UTF-8?q?=E8=BD=AE=E7=9A=84=E5=AD=90=E5=9B=BE=E5=86=99=E5=85=A5=E7=AE=97?= =?UTF-8?q?=E5=87=BA=E5=92=8C=E7=AC=AC=E4=B8=80=E8=BD=AE=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E9=94=AE=EF=BC=8C=E4=BA=A4=E5=9B=9E=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E8=BD=AE=E7=9A=84=E5=9B=9E=E6=89=A7=E3=80=81=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E5=B0=91=E6=94=B6=E4=B8=80=E7=AC=94=EF=BC=8C?= =?UTF-8?q?=E7=88=B6=E5=9B=BE=E9=87=8C=E5=90=8C=E5=90=8D=E7=9A=84=E8=8A=82?= =?UTF-8?q?=E7=82=B9=E3=80=81=E5=A5=97=E5=90=8C=E4=B8=80=E4=B8=AA=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=E7=9A=84=E4=B8=A4=E4=B8=AA=E5=AD=90=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=E8=8A=82=E7=82=B9=E4=B9=9F=E6=92=9E=E9=94=AE?= =?UTF-8?q?=E3=80=82=E5=8D=87=E7=BA=A7=E5=89=8D=E5=81=9C=E5=9C=A8=E5=AD=90?= =?UTF-8?q?=E5=9B=BE=E9=87=8C=E7=9A=84=E8=BF=90=E8=A1=8C=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=97=B6=E7=85=A7=E6=97=A7=E6=8C=89=E5=AD=90=E5=9B=BE=E9=87=8C?= =?UTF-8?q?=E7=9A=84=20id=20=E8=AE=A4=E5=87=BA=E5=B7=B2=E7=BB=8F=E7=94=9F?= =?UTF-8?q?=E6=95=88=E7=9A=84=E9=82=A3=E4=B8=80=E7=AC=94=E3=80=81=E4=B8=8D?= =?UTF-8?q?=E9=87=8D=E5=8F=91=EF=BC=88=E7=88=B6=E5=9B=BE=E9=87=8C=E6=9C=89?= =?UTF-8?q?=E5=90=8C=E5=90=8D=E8=8A=82=E7=82=B9=E6=97=B6=E4=B8=8D=E8=AE=A4?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/engine/context.py | 8 ++ backend/app/engine/nodes/multi.py | 36 +++--- backend/app/engine/nodes/tools.py | 13 ++- backend/app/process/actions.py | 33 ++++-- backend/tests/test_subgraph_repeat.py | 158 ++++++++++++++++++++++++++ 5 files changed, 222 insertions(+), 26 deletions(-) create mode 100644 backend/tests/test_subgraph_repeat.py diff --git a/backend/app/engine/context.py b/backend/app/engine/context.py index 6aff018f..24bbac71 100644 --- a/backend/app/engine/context.py +++ b/backend/app/engine/context.py @@ -43,6 +43,12 @@ class RunContext: """这张图里的节点 id → 这次运行里的 id(事件、工件上记的那个):最外层原样,子工作流里加前缀。""" return f"{self.prefix}{node_id}" + @property + def root_ids(self) -> frozenset[str]: + """这次运行最外层图上的节点 id:最外层就是这张图的,子工作流里是 multi.run_subgraph 交下来的。""" + given = self.extra.get(ROOT_IDS_KEY) + return frozenset(given) if given is not None else frozenset(n.id for n in self.spec.nodes) + def global_label(self, title: str) -> str: """这张图里的节点标题 → 这次运行里给人看的名字:最外层原样,子工作流里接上子工作流的名字(「子工作流 › x」,嵌套的 一层层接上,同 multi.run_subgraph 记在异常上的 label)。父运行的图上没有子工作流里的节点,各处照这个名字说。""" @@ -53,6 +59,8 @@ class RunContext: PREFIX_KEY = "node_prefix" #: 子工作流里的节点名前缀(「子工作流 › 」,同上):RunContext.global_label LABEL_PREFIX_KEY = "node_label_prefix" +#: 最外层图上的节点 id(同上,只在子工作流里有):RunContext.root_ids +ROOT_IDS_KEY = "root_node_ids" #: 父运行的事件流(RunContext.extra 里的键,同上)。子工作流在父节点里用 ainvoke 跑,LangGraph 不把它的 custom 事件交给 #: 父图的流(ainvoke 不开 custom 模式,get_stream_writer 拿到的是空的那个):子工作流里每一步以前在运行的事件里一条都 #: 没有,时间线看不到、封存也不含它查过的数。所以 run_subgraph 把父图那一刻的写入口交下来,子工作流的节点经它发 diff --git a/backend/app/engine/nodes/multi.py b/backend/app/engine/nodes/multi.py index 17222b1d..c2d5a152 100644 --- a/backend/app/engine/nodes/multi.py +++ b/backend/app/engine/nodes/multi.py @@ -14,7 +14,9 @@ from app.core.errors import describe_exception, raw_detail from app.core.events import EventType from app.db.base import SessionLocal from app.db.models import Workflow -from app.engine.context import LABEL_PREFIX_KEY, PREFIX_KEY, STREAM_KEY, NodeContext, NodeError, stream_writer +from app.engine.context import ( + LABEL_PREFIX_KEY, PREFIX_KEY, ROOT_IDS_KEY, STREAM_KEY, NodeContext, NodeError, stream_writer, +) from app.engine.lineage import whole_ref from app.engine.state import GraphState, message_text, template_context from app.engine.toolcalls import ( @@ -692,8 +694,16 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: # 子图里的节点经父运行的写入口发事件、节点 id 带前缀;嵌套的子图拿到的是同一个写入口、更长的前缀 extra={**{k: v for k, v in ctx.run.extra.items() if k not in ("resumed", "actors")}, PREFIX_KEY: f"{ctx.run.global_id(ctx.node.id)}/", LABEL_PREFIX_KEY: f"{ctx.run.global_label(ctx.node.title)} › ", - STREAM_KEY: stream_writer(ctx.run)}, + STREAM_KEY: stream_writer(ctx.run), + # 最外层图上的节点 id:子图里的写入认升级前按子图里的 id 记的幂等键时,排除父图同名节点记的那一条(tools._run_write) + ROOT_IDS_KEY: ctx.run.extra.get(ROOT_IDS_KEY) or frozenset(n.id for n in ctx.run.spec.nodes)}, ) + # 这个子工作流节点之前几次执行里,子图各节点跑过的记录(交回父图 trail 的,见下):子图从它们接着数第几次执行 + # (engine/evidence.next_exec)。以前每次都从空的 trail 起步,循环里第二轮的子图节点又记成第 1 次:台账分不出是哪一次, + # 写外部系统的幂等键(按第几次执行算)和第一轮撞上,第二轮的写入交回第一轮的回执、不发送 + prefix = f"{ctx.node.id}/" + seed = [{**t, "node_id": str(t.get("node_id"))[len(prefix):]} for t in state.get("trail") or [] + if isinstance(t, dict) and str(t.get("node_id") or "").startswith(prefix)] ctx.emit(EventType.LOG, level="info", message=f"进入子工作流「{workflow.name}」({len(sub_spec.nodes)} 个节点)") @@ -711,7 +721,7 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: "output": {}, "messages": [], "usage": {}, - "trail": [], + "trail": seed, "loops": {}, }, {"recursion_limit": limit}, @@ -732,6 +742,9 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: raise output = result_state.get("output") or {} + # 这一次执行新跑的那几步(trail 只追加:前面是交进去的 seed) + ran = [t for t in (result_state.get("trail") or [])[len(seed):] if isinstance(t, dict)] + result_state = {**result_state, "trail": ran} result = { "output": output, "workflow": workflow.name, @@ -754,11 +767,12 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: } if ledger := [_forwarded(e, ctx.node.id) for e in result_state.get("evidence") or [] if isinstance(e, dict)]: updates["evidence"] = ledger - if rejected := _rejected_steps(result_state, ctx.node.id): - # 子工作流里走了驳回出口的那几步(engine/compiler._rejected_exit 记的 outcome=rejected)交回父图的 trail:运行收尾 - # 据此在终态事件上照实写「途经驳回」(runner._rejected_via),实例页、通知、定时任务的 Webhook 说得出「子工作流 › 把关」 - # 被驳回、转到哪。名字、转到哪几步在子图里已经按这次运行的说法记好(global_label、global_id),这里只接上节点 id 的前缀 - updates["trail"] = rejected + if ran: + # 子图这一次跑过的每一步交回父图的 trail,节点 id 接上前缀(「子工作流节点 id/…」):同一个子工作流节点下一次执行时 + # 从这里接着数(上面的 seed);走了驳回出口的那几步(outcome=rejected)运行收尾据此在终态事件上照实写「途经驳回」 + # (runner._rejected_via),实例页、通知、定时任务的 Webhook 说得出「子工作流 › 把关」被驳回、转到哪(名字、转到哪几步 + # 在子图里已经按这次运行的说法记好:global_label、global_id)。父图自己的节点按自己的 id 数,不受影响 + updates["trail"] = [{**t, "node_id": f"{prefix}{t.get('node_id')}"} for t in ran] var_name = ctx.cfg("assign_to", "") if var_name: updates["vars"] = {var_name: output} @@ -769,12 +783,6 @@ async def run_subgraph(state: GraphState, ctx: NodeContext) -> dict[str, Any]: _LEDGER_NODE_KEYS = ("node_id", "card") -def _rejected_steps(sub_state: dict[str, Any], node_id: str) -> list[dict[str, Any]]: - """子工作流的 trail 里走了驳回出口的那几步,节点 id 换成父图这一层的(「子工作流节点 id/…」)。""" - return [{**t, "node_id": f"{node_id}/{t.get('node_id')}"} for t in sub_state.get("trail") or [] - if isinstance(t, dict) and t.get("outcome") == "rejected"] - - def _forwarded(entry: dict[str, Any], node_id: str) -> dict[str, Any]: """子图台账里的一条交回父图:节点 id 加上「子工作流节点 id/」前缀,别的照抄。""" out = dict(entry) diff --git a/backend/app/engine/nodes/tools.py b/backend/app/engine/nodes/tools.py index 7c299fcb..f2f61386 100644 --- a/backend/app/engine/nodes/tools.py +++ b/backend/app/engine/nodes/tools.py @@ -274,9 +274,14 @@ async def _run_write(state: GraphState, ctx: NodeContext, name: str, args: dict[ from app.process import actions exec_no = next_exec(state, ctx.node.id) + # 幂等键按这次运行里的节点 id 算(子工作流里的写「sub/pay」):以前按子图里的 id(pay)算,父图里同名的节点、套同一个 + # 工作流的两个子工作流节点撞同一个键,后发的那一笔交回先发的回执、不发送。升级前停在子图里的运行按子图里的 id 记过, + # 恢复时整个子图重跑:照旧认那个键(legacy_node_id),已经生效的不重发;父图里有同名节点时不认(那一条可能是它的) + node_id = ctx.run.global_id(ctx.node.id) + legacy = ctx.node.id if node_id != ctx.node.id and ctx.node.id not in ctx.run.root_ids else None try: - prep = await actions.prepare(run_id=ctx.run.run_id, node_id=ctx.node.id, exec_no=exec_no, - capability=capability, args=args) + prep = await actions.prepare(run_id=ctx.run.run_id, node_id=node_id, exec_no=exec_no, + capability=capability, args=args, legacy_node_id=legacy) except actions.ActionFailed as e: raise NodeError(ctx.node.id, str(e)) from e if prep.receipt is not None: @@ -317,8 +322,8 @@ async def _run_write(state: GraphState, ctx: NodeContext, name: str, args: dict[ # 外部系统会把审批人当成申请人;无人值守的写入没有人确认,不写 act on_behalf = {"sub": prep.run.get("started_by"), "act": confirmer} try: - result = await actions.send(prep, capability, run_id=ctx.run.run_id, node_id=ctx.node.id, - exec_no=exec_no, emit=ctx.emit, on_behalf=on_behalf) + result = await actions.send(prep, capability, run_id=ctx.run.run_id, node_id=node_id, + exec_no=exec_no, emit=ctx.emit, on_behalf=on_behalf, confirm_node=ctx.node.id) except actions.ActionFailed as e: raise NodeError(ctx.node.id, str(e)) from e return _write_updates(ctx, result) diff --git a/backend/app/process/actions.py b/backend/app/process/actions.py index 87ab7fca..14b4658b 100644 --- a/backend/app/process/actions.py +++ b/backend/app/process/actions.py @@ -236,6 +236,8 @@ class Prepared: run: dict[str, Any] = field(default_factory=dict) #: 按参数定义收好类型的参数(typed_args):哈希、预览按它算,send 发的也是它 args: dict[str, Any] = field(default_factory=dict) + #: 记录上写的节点 id:认出升级前那种键(legacy_node_id)时照它记,同一个键下的记录 node_id 一致,下一次还认得出 + node_id: str | None = None @property def idem_key(self) -> str: @@ -247,8 +249,11 @@ async def latest(session: AsyncSession, key: str) -> ActionRecord | None: .order_by(ActionRecord.attempt.desc()).limit(1)) -async def prepare(*, run_id: str, node_id: str, exec_no: int, capability: Any, args: dict[str, Any]) -> Prepared: - """节点每次执行都先走这里:按 action_key 查记录,决定发不发、第几次发、走哪个环境。 +async def prepare(*, run_id: str, node_id: str, exec_no: int, capability: Any, args: dict[str, Any], + legacy_node_id: str | None = None) -> Prepared: + """节点每次执行都先走这里:按 action_key 查记录,决定发不发、第几次发、走哪个环境。node_id 是这次运行里的节点 id + (子工作流里的带前缀);legacy_node_id 是升级前子工作流里按子图里的 id 记的那种(tools._run_write 交进来),按它也没查到 + 才算头一次。 sent / unknown 抛 ActionPending(不发);failed 且没有人核实为「重新发送」的抛 ActionFailed。先查记录再收类型:已生效 (演练过)的交回存下的回执、已发出 / 结果未知的挂起,都和这一次渲染出来的参数无关——发出之后能力的参数定义改了、手上的 @@ -263,13 +268,21 @@ async def prepare(*, run_id: str, node_id: str, exec_no: int, capability: Any, a run = await session.get(Run, run_id) endpoint = await _endpoint(session, capability) prev = await latest(session, key) + if prev is None and legacy_node_id: + # 升级前子工作流里的写入按子图里的节点 id 记幂等键(node_id 也记成它):恢复时整个子图重跑,照那个键认出已经 + # 记下的那一笔,接着用它的键(不重发、第几次发送接着数)。新记的子图写入 node_id 带前缀,认不到这里来 + old_key = action_key(run_id, legacy_node_id, exec_no) + old = await latest(session, old_key) + if old is not None and old.node_id == legacy_node_id: + key, prev, node_id = old_key, old, legacy_node_id facts = {"app_id": run.app_id, "workflow_id": run.workflow_id, "workflow_version": run.version, "graph_hash": run.version_hash, "started_by": run.started_by} if run is not None else {} endpoint_name = endpoint.name if endpoint is not None else None if prev is not None and prev.state in ("confirmed", "simulated"): # 交回的是生效的那一次:指纹、预览、环境、第几次都照记录,不按这一次的参数重算 return Prepared(key=key, attempt=prev.attempt, payload_hash=prev.payload_hash, preview=prev.preview, - environment=prev.environment, endpoint=endpoint_name, receipt=_receipt_of(prev), run=facts) + environment=prev.environment, endpoint=endpoint_name, receipt=_receipt_of(prev), run=facts, + node_id=node_id) if prev is not None and prev.state in ("sent", "unknown"): raise ActionPending(prev.id) formal = run is not None and run.run_class == "formal" @@ -290,15 +303,18 @@ async def prepare(*, run_id: str, node_id: str, exec_no: int, capability: Any, a args = typed_args(capability, args) return Prepared(key=key, attempt=attempt, payload_hash=payload_hash(name, args), preview=masked(args, masked_paths(capability)), environment=environment, endpoint=endpoint_name, - run=facts, args=args) + run=facts, args=args, node_id=node_id) async def send(prep: Prepared, capability: Any, *, run_id: str, node_id: str, exec_no: int, - emit: Callable[..., None], on_behalf: str | dict[str, Any] | None = None) -> dict[str, Any]: + emit: Callable[..., None], on_behalf: str | dict[str, Any] | None = None, + confirm_node: str | None = None) -> dict[str, Any]: """先写记录再发送。返回节点的产出;结果未知抛 ActionPending,确定失败抛 ActionFailed。发的是 prepare 收好类型的 那一份参数(prep.args),和确认卡上的哈希是同一份。 - on_behalf 原样交给平台执行器写身份断言:{"sub": 运行的发起人, "act": 确认人}(capabilities.endpoints.on_behalf_parts)。""" + on_behalf 原样交给平台执行器写身份断言:{"sub": 运行的发起人, "act": 确认人}(capabilities.endpoints.on_behalf_parts)。 + 记录上的节点 id 照 prepare 认定的(prep.node_id);confirm_node 是动作确认待办上记的节点 id(确认卡上的,子工作流里是 + 子图里的 id),不给就按 node_id 找。""" from app.capabilities.registry import endpoint_request, masked_paths name = capability.name @@ -306,11 +322,12 @@ async def send(prep: Prepared, capability: Any, *, run_id: str, node_id: str, ex rec = await session.scalar(select(ActionRecord).where(ActionRecord.idem_key == prep.idem_key)) if rec is None: rec = ActionRecord( - action_key=prep.key, attempt=prep.attempt, idem_key=prep.idem_key, run_id=run_id, node_id=node_id, + action_key=prep.key, attempt=prep.attempt, idem_key=prep.idem_key, run_id=run_id, + node_id=prep.node_id or node_id, exec_no=exec_no, capability=name, capability_version=getattr(capability, "version", None), endpoint_id=getattr(capability, "endpoint_id", None), environment=prep.environment, payload_hash=prep.payload_hash, preview=prep.preview, state="pending", - confirm_task_id=await _confirm_task(session, run_id, node_id), + confirm_task_id=await _confirm_task(session, run_id, confirm_node or node_id), app_id=prep.run.get("app_id"), workflow_id=prep.run.get("workflow_id"), workflow_version=prep.run.get("workflow_version"), graph_hash=prep.run.get("graph_hash")) session.add(rec) diff --git a/backend/tests/test_subgraph_repeat.py b/backend/tests/test_subgraph_repeat.py new file mode 100644 index 00000000..e7ebac88 --- /dev/null +++ b/backend/tests/test_subgraph_repeat.py @@ -0,0 +1,158 @@ +"""同一个子工作流节点在一次运行里执行多次(循环体里、退回修改绕回来):子图里的节点每次执行都要分得开。 + +以前子图每次都从空状态起步,里面的节点每次都记成「第 1 次执行」: +- 台账里同一个节点(「sub/fetch」)几条查询都记 exec 1,分不出是第几次; +- 写外部系统的幂等键按 (运行, 节点 id, 第几次执行) 算,子图里的节点 id 还是子图里的(pay,不带前缀):循环第二轮的 + 写入算出同一个键,找到第一轮生效的记录,交回第一轮的回执、这一轮不发送——外部系统少收一笔,界面上只有一句「已经生效」; + 父图里同名的节点、两个子工作流节点套同一个工作流时也撞同一个键。 +现在:子图里的节点接着父运行里同一个子工作流节点之前几次数(exec 1、2…),幂等键按这次运行里的节点 id(「sub/pay」)算; +升级前停下的运行(子图里的写入按子图里的 id 记的),恢复时照旧认得出已经生效的那一笔,不重发。 +图、能力、外部系统都是虚构的。 +""" +from __future__ import annotations + +import asyncio + +import pytest +from sqlalchemy import select + +from app.db.base import SessionLocal +from app.db.models import ActionRecord, Run, RunEvent +from app.engine.runner import run_manager +from app.process import actions +from tests import ledger_samples as S +from tests.fakes.fake_erp import PAY, FakeErp, endpoint_row, install + + +@pytest.fixture(autouse=True) +async def engine_up(): + await run_manager.setup() + yield + await run_manager.shutdown() + + +def node(nid, ntype, label=None, **config): + return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": label or nid, "config": config}} + + +def edge(source, target, handle=None): + return {"source": source, "target": target, **({"sourceHandle": handle} if handle else {})} + + +async def _finish(graph: dict, **kwargs) -> Run: + run = await run_manager.start(graph=graph, input_payload={}, **kwargs) + for _ in range(600): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed", "interrupted") and not run_manager.is_active(run.id): + return row + raise AssertionError(f"运行 {run.id} 没跑完:{row.status}") + + +async def _events(run_id: str, kind: str, node_id: str | None = None) -> list[RunEvent]: + async with SessionLocal() as session: + q = select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == kind) + if node_id: + q = q.where(RunEvent.node_id == node_id) + return list((await session.execute(q.order_by(RunEvent.seq))).scalars()) + + +def looped(sub_id: str, *, before: list[dict] | None = None) -> dict: + """开始 → (before…) → 循环两轮,每轮跑一次子工作流 → 成果。""" + nodes = [node("start", "input"), *(before or []), + node("each", "loop", "逐项", mode="foreach", items="[1, 2]", item_var="it", max_iterations=5), + node("sub", "subgraph", "子流程", workflow_id=sub_id, workflow_version=1, input={"n": "{{ vars.it }}"}), + node("out", "output", fields=[{"name": "r", "value": "完成"}])] + chain = ["start", *[b["id"] for b in before or []], "each"] + edges = [edge(a, b) for a, b in zip(chain, chain[1:])] + edges += [edge("each", "sub", "body"), edge("sub", "each"), edge("each", "out", "done")] + return {"nodes": nodes, "edges": edges} + + +async def test_inner_steps_count_on_across_invocations(tmp_path): + await S.fresh_source(tmp_path / "shop.db") + inner = S.chain(node("s", "input"), node("fetch", "tool", tool=S.tool(), args={"sql": "SELECT COUNT(*) AS n FROM orders"}), + node("o", "output", fields=[{"name": "n", "value": "{{ nodes.fetch.text }}"}])) + sub_id = await S.publish("取数子流程", graph=inner, level="draft") + row = await _finish(looped(sub_id)) + assert row.status == "succeeded", row.error + finished = await _events(row.id, "node.finished", "sub/fetch") + execs = [e["exec"] for f in finished for e in f.data.get("evidence") or [] if e.get("kind") == "query"] + assert execs == [1, 2], "第二轮是子图里那一步的第 2 次执行,不再又记成第 1 次" + + +@pytest.fixture +def erp() -> FakeErp: + return FakeErp() + + +async def _unattended(monkeypatch, erp) -> None: + """付款能力设成可以无人值守写入、节点不要确认:两轮都直接发,不停下等人(和确认无关,只看幂等键分不分得开)。""" + caps = install(monkeypatch, erp, await endpoint_row(test=True), lookup=False) + caps[PAY].unattended_write = True + + +def pay(nid: str = "pay") -> dict: + return node(nid, "tool", "付款", tool=PAY, approval="never", args={"amount": "{{ input.n }}", "card": "6222020200112233"}) + + +async def test_a_write_inside_a_loop_is_sent_on_every_round(monkeypatch, erp): + await _unattended(monkeypatch, erp) + inner = S.chain(node("s", "input", fields=[{"name": "n"}]), pay(), + node("o", "output", fields=[{"name": "单号", "value": "{{ nodes.pay.receipt.id }}"}])) + sub_id = await S.publish("付款子流程", graph=inner, level="draft") + row = await _finish(looped(sub_id)) + assert row.status == "succeeded", row.error + assert len(erp.writes("test")) == 2, "第二轮的写入照发,不交回第一轮的回执" + async with SessionLocal() as session: + records = list((await session.execute(select(ActionRecord).where(ActionRecord.run_id == row.id) + .order_by(ActionRecord.created_at))).scalars()) + assert [(r.node_id, r.exec_no) for r in records] == [("sub/pay", 1), ("sub/pay", 2)] + assert len({r.action_key for r in records}) == 2 + assert not [e for e in await _events(row.id, "log") if e.data.get("code") == "action_already_applied"] + + +async def test_a_namesake_in_the_parent_does_not_share_the_key(monkeypatch, erp): + """父图里也有一个 pay:子图里的 pay 不再和它撞同一个幂等键。""" + await _unattended(monkeypatch, erp) + inner = S.chain(node("s", "input", fields=[{"name": "n"}]), pay(), node("o", "output", fields=[{"name": "r", "value": "x"}])) + sub_id = await S.publish("付款子流程", graph=inner, level="draft") + graph = S.chain(node("start", "input", fields=[{"name": "n"}]), pay(), + node("sub", "subgraph", "子流程", workflow_id=sub_id, workflow_version=1, input={"n": "7"}), + node("out", "output", fields=[{"name": "r", "value": "完成"}])) + run = await run_manager.start(graph=graph, input_payload={"n": 5}) + for _ in range(600): + await asyncio.sleep(0.05) + async with SessionLocal() as session: + row = await session.get(Run, run.id) + if row.status in ("succeeded", "failed") and not run_manager.is_active(run.id): + break + assert row.status == "succeeded", row.error + assert len(erp.writes("test")) == 2 + async with SessionLocal() as session: + ids = sorted(r.node_id for r in (await session.execute( + select(ActionRecord).where(ActionRecord.run_id == row.id))).scalars()) + assert ids == ["pay", "sub/pay"] + + +async def test_a_write_recorded_before_the_upgrade_is_still_found(monkeypatch, erp): + """升级前子图里的写入按子图里的 id(pay)记幂等键。停在子图里的运行恢复时整个子图重跑:照旧按那个键找到已经生效的 + 记录、交回回执,不重发。父图里有同名节点时不这样认(那一条可能是父图那个节点的)。""" + caps = install(monkeypatch, erp, await endpoint_row(test=True), lookup=False) + async with SessionLocal() as session: + run = Run(graph={"nodes": [node("sub", "subgraph")], "edges": []}, status="running", run_class="exploratory") + session.add(run) + await session.flush() + legacy = actions.action_key(run.id, "pay", 1) + session.add(ActionRecord(action_key=legacy, attempt=1, idem_key=f"{legacy}:1", run_id=run.id, node_id="pay", + exec_no=1, capability=PAY, environment="test", payload_hash="0" * 64, state="confirmed", + receipt={"summary": {"id": "PAY-OLD"}})) + await session.commit() + run_id = run.id + args = {"amount": "5", "card": "6222020200112233"} + found = await actions.prepare(run_id=run_id, node_id="sub/pay", exec_no=1, capability=caps[PAY], args=args, + legacy_node_id="pay") + assert found.key == legacy and found.receipt is not None + fresh = await actions.prepare(run_id=run_id, node_id="sub/pay", exec_no=1, capability=caps[PAY], args=args) + assert fresh.key != legacy and fresh.receipt is None From edde451d199d7aadf15ac7b8f6441f2f09cdcc1c Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 19 Oct 2026 09:34:14 +0800 Subject: [PATCH 07/15] =?UTF-8?q?=E9=97=AE=E6=95=B0=E6=8D=AE=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E6=97=A5=E5=BF=97=EF=BC=9A=E5=8A=A9=E6=89=8B=E7=9A=84?= =?UTF-8?q?=E5=BF=83=E8=B7=B3=E4=B8=8D=E8=90=BD=E5=BA=93=E3=80=81=E5=B7=B2?= =?UTF-8?q?=E7=BB=93=E6=9D=9F=E7=9A=84=E8=BD=AE=E6=AC=A1=E6=8C=89=E6=9C=9F?= =?UTF-8?q?=E6=B8=85=E7=90=86=E3=80=82=E5=BF=83=E8=B7=B3=EF=BC=88=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=87=A0=E7=A7=92=E6=B2=A1=E5=87=BA=E5=A3=B0=E6=97=B6?= =?UTF-8?q?=E7=9A=84=20heartbeat=20=E6=93=8D=E4=BD=9C=EF=BC=89=E6=98=AF?= =?UTF-8?q?=E7=BA=AF=E4=BF=9D=E6=B4=BB=EF=BC=8C=E4=BB=A5=E5=89=8D=E6=AF=8F?= =?UTF-8?q?=203=20=E7=A7=92=E8=90=BD=E4=B8=80=E6=9D=A1=E5=BA=93=EF=BC=9B?= =?UTF-8?q?=E7=8E=B0=E5=9C=A8=E5=8F=AA=E5=9C=A8=E6=9C=AC=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E9=87=8C=E8=AE=B0=E6=9C=80=E8=BF=91=E7=9A=84=E4=B8=80=E6=8B=8D?= =?UTF-8?q?=EF=BC=88chat/log.beat=EF=BC=89=EF=BC=8C=E6=B5=81=E4=B8=8A?= =?UTF-8?q?=E5=8F=91=E4=B8=80=E6=9D=A1=E4=B8=8D=E5=B8=A6=20seq=20=E7=9A=84?= =?UTF-8?q?=EF=BC=8C=E9=87=8D=E8=BF=9E=E6=97=B6=E6=B5=81=E5=85=88=E8=A1=A5?= =?UTF-8?q?=E6=9C=80=E8=BF=91=E7=9A=84=E9=82=A3=E4=B8=80=E6=8B=8D=EF=BC=8C?= =?UTF-8?q?=E4=B9=8B=E5=90=8E=E8=90=BD=E4=BA=86=E4=BB=BB=E4=BD=95=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=B0=B1=E4=BD=9C=E5=BA=9F=EF=BC=9B=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E7=85=A7=E6=94=B6=E4=B8=8D=E5=B8=A6=20seq=20=E7=9A=84=E5=BF=83?= =?UTF-8?q?=E8=B7=B3=E3=80=81=E4=B8=8D=E5=8A=A8=E6=94=B6=E5=88=B0=E7=9A=84?= =?UTF-8?q?=E6=9C=80=E5=90=8E=E4=B8=80=E4=B8=AA=20seq=E3=80=82=E5=85=B6?= =?UTF-8?q?=E4=BD=99=E4=BA=8B=E4=BB=B6=E7=85=A7=E8=90=BD=EF=BC=88=E7=AC=AC?= =?UTF-8?q?=202=20=E6=B3=A2=E8=B7=A8=E8=BF=9B=E7=A8=8B=E4=BB=8E=E5=BA=93?= =?UTF-8?q?=E9=87=8C=E6=8C=89=20seq=20=E8=BF=BD=EF=BC=89=E3=80=82=E5=B7=B2?= =?UTF-8?q?=E7=BB=93=E6=9D=9F=EF=BC=88=E6=88=90=E5=8A=9F=E3=80=81=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E3=80=81=E5=8F=96=E6=B6=88=E3=80=81=E4=B8=AD=E6=96=AD?= =?UTF-8?q?=EF=BC=89=E8=B6=85=E8=BF=87=2014=20=E5=A4=A9=E7=9A=84=E8=BD=AE?= =?UTF-8?q?=E6=AC=A1=E6=95=B4=E8=BD=AE=E5=88=A0=E4=BA=8B=E4=BB=B6=E6=97=A5?= =?UTF-8?q?=E5=BF=97=EF=BC=88chat/log.purge=EF=BC=8C=E6=8C=82=E5=9C=A8?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E5=BE=AA=E7=8E=AF=E7=9A=84=E9=82=A3=E4=B8=80?= =?UTF-8?q?=E6=8B=8D=E9=87=8C=EF=BC=8C=E4=B8=80=E5=B0=8F=E6=97=B6=E6=9C=80?= =?UTF-8?q?=E5=A4=9A=E4=B8=80=E6=AC=A1=EF=BC=89=EF=BC=9A=E9=87=8D=E5=BC=80?= =?UTF-8?q?=E5=B7=B2=E7=BB=93=E6=9D=9F=E7=9A=84=E4=B8=80=E8=BD=AE=E8=AF=BB?= =?UTF-8?q?=E7=9A=84=E6=98=AF=20conversation=5Fturns=20=E9=82=A3=E4=B8=80?= =?UTF-8?q?=E8=A1=8C=EF=BC=8C=E4=B8=8D=E8=AF=BB=E4=BA=8B=E4=BB=B6=E6=97=A5?= =?UTF-8?q?=E5=BF=97=EF=BC=9B=E4=BB=BB=E5=8A=A1=E8=A1=8C=E7=95=99=E7=9D=80?= =?UTF-8?q?=EF=BC=8Cseq=20=E6=8E=A5=E7=9D=80=E5=8F=91=E5=8F=B7=E3=80=82REA?= =?UTF-8?q?DME=20=E6=95=B0=E6=8D=AE=E4=BD=8D=E7=BD=AE=E5=86=99=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 + backend/app/chat/job.py | 12 ++- backend/app/chat/log.py | 92 +++++++++++++++++++-- backend/app/scheduler/loop.py | 7 +- backend/tests/test_chat_turns.py | 133 ++++++++++++++++++++++++++++++- frontend/src/api/chatTurns.ts | 7 +- frontend/src/store/chat.ts | 3 +- scripts/check-chat.mjs | 7 +- 8 files changed, 252 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2c2f2f2c..92895acc 100644 --- a/README.md +++ b/README.md @@ -1022,6 +1022,10 @@ pip install -e 'backend[microvm,dev,db,docs]' -c backend/requirements.lock # `.secret_key` 加密密钥。 删除 `data/` 即恢复出厂设置。 +问数据每一轮的事件日志(`agentlab.db` 的 `chat_turn_events`:助手的操作、阶段、运行、复核、终局,供页面先回放再接实时)只为 +进行中的轮次服务:已结束(成功、失败、取消、中断)超过 14 天的轮次由调度循环每小时清一次、整轮删除;重开已结束的一轮读的是 +对话里那一行(答案、图、复核、用量、几次尝试),不受影响。助手等模型时的心跳只在实时流上发、不落库。 + --- ## 常见问题 diff --git a/backend/app/chat/job.py b/backend/app/chat/job.py index 2d04bf4d..d993beab 100644 --- a/backend/app/chat/job.py +++ b/backend/app/chat/job.py @@ -16,7 +16,7 @@ **事件日志**(chat/log.py;每条 {seq, type, data, ts}): turn.attempt {mode, attempts, scope, max_steps?} 这一次尝试开始了(发起、重试本轮、重新运行、跑一下、继续运行、接着收尾) - op {...} 助手的一条操作原样(含心跳、查看、final、usage) + op {...} 助手的一条操作原样(含查看、final、usage;心跳不落库,只在实时流上发,见 chat/log.beat) stage {stage} 到了哪一步:build 建图、run 运行、waiting 停在审批上等人、review 复核 run {run_id, status, run_class} 发起了一次运行(页面据此接上运行自己的实时流看过程) review {result} 复核结论原样(含 usage、retry_instruction) @@ -132,6 +132,10 @@ class ChatDriver(TurnDriver): await self._flush_thinking() return await self._flush_thinking() + if kind == "heartbeat": + # 纯保活:只在实时流上发、不落库(chat/log.beat;以前每 3 秒落一条) + turn_log.beat(self.execution.turn_id, op) + return await self.execution.emit("op", op) if kind == "look" and (look := record.look_event(op)) is not None: self.looks.append(look) @@ -336,6 +340,12 @@ class ChatTurnExecution(runtime.JobRun): # ---------------- 执行 ---------------- async def _run(self) -> None: + try: + await self._execute() + finally: + turn_log.forget(self.turn_id) # 进程里记的最近一拍心跳(chat/log.beat)跟着这一次执行收掉 + + async def _execute(self) -> None: from app.api.deps import set_principal from app.chat import turn as policy from app.db.models import ConversationTurn diff --git a/backend/app/chat/log.py b/backend/app/chat/log.py index 9e73c8b1..c41fdd59 100644 --- a/backend/app/chat/log.py +++ b/backend/app/chat/log.py @@ -11,28 +11,49 @@ 线上格式(SSE,每条一行 data: ):{seq, type, data, ts};回放完、这一轮也不在进行中了,补一条不带 seq 的 {type: stream.end, status}(任务的状态)再关;隔一拍没有新事件发一行注释心跳「: ping」(页面据此知道连接还活着)。 事件种类见 app/chat/job.py 的模块说明。 + +**助手的心跳不落库。** 模型几秒没出声时助手发一拍 {op: heartbeat, phase, elapsed_ms}(页面据此写阶段、已等多久):它是纯保活, +以前每 3 秒落一条,一轮几百条。现在只记在本进程里(beat:每一轮最近的一拍),流上发一条不带 seq 的 {type: op, data};重连时流 +先补最近的那一拍。之后落了任何一条事件,那一拍就作废(心跳只说「上一条事件之后还在等」,重连时补旧的会让页面的进度退回去)。 +其余事件照落:第 2 波跨进程的实时流从库里按 seq 追,别的进程的心跳那时由流按任务状态自己合成。 + +**清理。** 已结束(成功、失败、取消、中断)超过 RETAIN_DAYS 天的轮次整轮删事件日志(purge,挂在调度循环的那一拍里,一小时最多一次): +页面重开已结束的一轮读的是 conversation_turns 那一行(答案、图、复核、用量、几次尝试都在那里),只有还在进行中的轮次才回放 +事件日志。任务行留着:last_seq 接着发号,之后在这一轮上重试、继续运行不撞号。 """ from __future__ import annotations import asyncio import contextlib +import itertools import json +import logging import time from collections import defaultdict from collections.abc import AsyncIterator, Awaitable, Callable, Iterator +from datetime import datetime, timedelta from typing import Any -from sqlalchemy import func, select, update +from sqlalchemy import delete, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.db.base import SessionLocal +logger = logging.getLogger(__name__) + #: 隔多久没有新事件就发一拍心跳(秒),也是订阅方回库里看一次的间隔 HEARTBEAT_S = 15.0 #: 一次回放最多读多少条 READ_BATCH = 500 #: 回放、实时推送完、这一轮不在进行中时的结束标记(不落库、不带 seq) STREAM_END = "stream.end" +#: 已结束的轮次的事件日志留几天 +RETAIN_DAYS = 14 +#: 清理多久最多跑一次(秒)、一次最多清几轮 +PURGE_EVERY_S = 3600.0 +PURGE_BATCH = 500 +#: 结束了的任务状态(core/job_runtime 的状态词里不再往下走的那几种) +FINISHED = ("succeeded", "failed", "cancelled", "interrupted") class _Waiters: @@ -67,6 +88,27 @@ def notify(turn_id: str) -> None: waiters.notify(turn_id) +#: 每一轮最近的一拍心跳:(第几拍, 操作)。只在本进程里,不落库(见模块说明) +_beats: dict[str, tuple[int, dict[str, Any]]] = {} +_beat_no = itertools.count(1) + + +def beat(turn_id: str, op: dict[str, Any]) -> None: + """助手的一拍心跳:记下最近的这一拍、叫醒订阅方,由流发出去(不落库、不占 seq)。""" + _beats[turn_id] = (next(_beat_no), dict(op)) + notify(turn_id) + + +def latest_beat(turn_id: str) -> dict[str, Any] | None: + got = _beats.get(turn_id) + return dict(got[1]) if got else None + + +def forget(turn_id: str) -> None: + """这一轮结束了:进程里记的那一拍清掉。""" + _beats.pop(turn_id, None) + + async def add(session: AsyncSession, turn_id: str, type_: str, data: dict[str, Any] | None = None) -> int: """在调用方的事务里加一条(不提交),交回它的 seq。 @@ -91,6 +133,8 @@ async def append(session: AsyncSession, turn_id: str, type_: str, data: dict[str """追加一条,提交,叫醒订阅方。交回它的 seq(见 add)。""" seq = await add(session, turn_id, type_, data) await session.commit() + # 落了新事件,上一拍心跳作废:重连时不再补它(心跳只说「上一条事件之后还在等」) + _beats.pop(turn_id, None) notify(turn_id) return seq @@ -124,8 +168,10 @@ async def stream(turn_id: str, after: int, *, active: Callable[[], Awaitable[str """SSE 的每一帧:先回放 seq > after 的,再推实时的;这一轮不在进行中、也没有新的了,补一条 stream.end 再结束。 active() 交回这一轮还在进行中时为 None,不在进行中时交回任务的状态(stream.end 里带的就是它)。""" - beat = HEARTBEAT_S if heartbeat is None else heartbeat + interval = HEARTBEAT_S if heartbeat is None else heartbeat last = after + #: 这条连接发过的最近一拍助手心跳(_beats 的第几拍):新连上的从 0 起,先补最近的那一拍 + sent = 0 while True: with waiters.watch(turn_id) as woke: rows = await read(turn_id, last) @@ -138,11 +184,47 @@ async def stream(turn_id: str, after: int, *, active: Callable[[], Awaitable[str if status is not None: yield frame({"type": STREAM_END, "status": status, "data": {"status": status}}) return + got = _beats.get(turn_id) + if got is not None and got[0] > sent: + sent = got[0] + # 不带 seq:页面按 seq 补齐时不算它(frontend/src/api/chatTurns.ts) + yield frame({"type": "op", "data": got[1], "ts": time.time()}) + continue try: - await asyncio.wait_for(woke.wait(), timeout=beat) + await asyncio.wait_for(woke.wait(), timeout=interval) except asyncio.TimeoutError: yield PING -__all__ = ["HEARTBEAT_S", "PING", "READ_BATCH", "STREAM_END", "add", "append", "frame", "notify", "read", "stream", "waiters", - "wire"] +async def purge(now: datetime, *, retain_days: float = RETAIN_DAYS, limit: int = PURGE_BATCH) -> list[str]: + """结束超过 retain_days 天的轮次整轮删事件日志(见模块说明),交回清了哪几轮。任务行、conversation_turns 那一行不动。 + 结束的时刻按任务的 finished_at,没记的按 updated_at。""" + from app.db.models_chat import ChatTurnEvent, ChatTurnJob + + cutoff = now - timedelta(days=retain_days) + async with SessionLocal() as session: + ended = select(ChatTurnJob.id).where(ChatTurnJob.status.in_(FINISHED), + func.coalesce(ChatTurnJob.finished_at, ChatTurnJob.updated_at) < cutoff) + turns = list((await session.execute(select(ChatTurnEvent.turn_id).where(ChatTurnEvent.turn_id.in_(ended)) + .distinct().limit(limit))).scalars()) + if turns: + await session.execute(delete(ChatTurnEvent).where(ChatTurnEvent.turn_id.in_(turns)) + .execution_options(synchronize_session=False)) + await session.commit() + return turns + + +_last_purge = 0.0 + + +async def purge_tick(now: datetime) -> list[str]: + """调度循环每一拍调(scheduler/loop.process_due):一小时最多清一次。""" + global _last_purge + if time.monotonic() - _last_purge < PURGE_EVERY_S and _last_purge: + return [] + _last_purge = time.monotonic() + return await purge(now) + + +__all__ = ["FINISHED", "HEARTBEAT_S", "PING", "READ_BATCH", "RETAIN_DAYS", "STREAM_END", "add", "append", "beat", "forget", + "frame", "latest_beat", "notify", "purge", "purge_tick", "read", "stream", "waiters", "wire"] diff --git a/backend/app/scheduler/loop.py b/backend/app/scheduler/loop.py index 59948cb6..6d231434 100644 --- a/backend/app/scheduler/loop.py +++ b/backend/app/scheduler/loop.py @@ -141,16 +141,19 @@ async def process_due(now: datetime | None = None) -> None: """流程的一拍(app/process):待办的提醒、升级、超时驳回(永不自动批准);结果未知的写入按退避去查; 上传了却一直没提交的文件过了时限清掉(app/files,每小时一次);访问密钥的请求记录批量写库、过了保留期的清掉 (auth/keys.py,每小时清一次:每个请求同步写一行会和运行抢 SQLite 的写锁,所以攒到这一拍写);每天到点发待办汇总、 - 访问密钥到期前 7 天和 1 天各提醒一次(notify/digest.py,04 细案 B3:按日期认领、按审计去重,推送放在后台,不拖这一拍)。 + 访问密钥到期前 7 天和 1 天各提醒一次(notify/digest.py,04 细案 B3:按日期认领、按审计去重,推送放在后台,不拖这一拍); + 已结束超过保留期的问数据轮次整轮删事件日志(chat/log.purge_tick,每小时一次)。 各自出错只记日志:这一拍出错不该让调度循环停下,下一拍照常再查。""" from app.auth import keys + from app.chat import log as chat_log from app.files import store as files_store from app.notify import digest from app.process import actions, tasks for name, step in (("待办的时限处理", tasks.tick), ("写入结果的查询", actions.tick), ("没有提交的上传文件清理", files_store.cleanup_tick), ("访问密钥的请求记录", keys.tick), - ("每天的待办汇总", digest.digest_due), ("访问密钥快到期的提醒", digest.keys_due)): + ("每天的待办汇总", digest.digest_due), ("访问密钥快到期的提醒", digest.keys_due), + ("已结束的问数据轮次的事件日志清理", chat_log.purge_tick)): try: done = await step(now) if isinstance(done, list) and done: diff --git a/backend/tests/test_chat_turns.py b/backend/tests/test_chat_turns.py index fff8502e..b0d2c12d 100644 --- a/backend/tests/test_chat_turns.py +++ b/backend/tests/test_chat_turns.py @@ -239,7 +239,8 @@ async def test_the_stream_replays_then_follows_and_reconnects_by_seq(client, mon first: list[dict[str, Any]] = [] stream = turn_log.stream(turn_id, 0, active=active, heartbeat=0.2) async for frame in stream: - if frame.startswith("data: "): + # 助手的心跳不带 seq、不落库(chat/log.beat):按 seq 补齐时不算它,同页面 + if frame.startswith("data: ") and "seq" in json.loads(frame[6:]): first.append(json.loads(frame[6:])) if any(e.get("type") == "stage" for e in first): break @@ -253,7 +254,9 @@ async def test_the_stream_replays_then_follows_and_reconnects_by_seq(client, mon if frame == turn_log.PING: pings += 1 continue - rest.append(json.loads(frame[6:])) + event = json.loads(frame[6:]) + if "seq" in event or event.get("type") == "stream.end": + rest.append(event) assert rest[-1]["type"] == "stream.end" and rest[-1]["status"] == "succeeded" got = [e["seq"] for e in first + rest[:-1]] assert got == list(range(1, len(got) + 1)), "补齐、不重复" @@ -935,3 +938,129 @@ async def test_a_stop_that_lands_while_launching_still_cancels_the_run(client, m assert (await session.get(Run, job.run_id)).status == "cancelled", "这次运行跟着取消了,没有没人管的运行" row = await _row(turn_id) assert row.status == "error" and row.error == "已取消" and row.meta["runId"] == job.run_id + + +# -------------------------------------------------------------------------- +# 事件日志:心跳不落库、已结束的轮次按期清理 +# -------------------------------------------------------------------------- + + +async def test_heartbeats_go_out_on_the_stream_but_are_not_stored(client, monkeypatch, source): + """心跳是纯保活(模型几秒没出声时助手发的一拍 heartbeat 操作):只在实时流上发、不落库,重连时流自己补最近的一拍; + 落库的事件(思考、操作、阶段……)照旧。以前每 3 秒落一条,一轮下来几百条心跳。""" + from app.api import copilot + + gate = asyncio.Event() + es.use_copilot(monkeypatch, Gated(source["name"], gate)) + monkeypatch.setattr(copilot, "_HEARTBEAT_SECONDS", 0.05) # use_copilot 把心跳调成了一小时一拍 + es.use_report(monkeypatch) + conversation_id = await _conversation(client) + turn_id = await _ask(client, conversation_id, datasource_ids=[source["id"]]) + await _stage(turn_id, "build") + + async def active() -> str | None: + return await chat_job.stream_status(turn_id) + + beats: list[dict[str, Any]] = [] + stream = turn_log.stream(turn_id, 0, active=active, heartbeat=0.2) + async for frame in stream: + if frame.startswith("data: "): + event = json.loads(frame[6:]) + if event.get("type") == "op" and event["data"].get("op") == "heartbeat": + beats.append(event) + if len(beats) >= 2: + break + await stream.aclose() + assert all("seq" not in b for b in beats), "心跳不占 seq:重连按 seq 补齐时不会多出来、也不会漏" + # 重连(从最后一条落库的事件接着读):流先补一拍最近的心跳,页面接着知道还在等模型 + stored = await _events(turn_id) + again = turn_log.stream(turn_id, stored[-1]["seq"], active=active, heartbeat=0.2) + first = await again.__anext__() + await again.aclose() + assert first.startswith("data: ") and json.loads(first[6:])["data"]["op"] == "heartbeat" + + gate.set() + await _done(turn_id) + events = await _events(turn_id) + assert not [e for e in events if e["type"] == "op" and e["data"].get("op") == "heartbeat"], "心跳一条都不落库" + assert [e["seq"] for e in events] == list(range(1, len(events) + 1)) + assert turn_log.latest_beat(turn_id) is None, "这一轮结束了,进程里记的那一拍跟着清掉" + + +async def test_a_later_event_supersedes_the_last_heartbeat(): + """心跳只说「上一条事件之后还在等」:之后落了新事件,重连时不再补那一拍(不然页面的进度会退回去)。""" + async with SessionLocal() as session: + conversation = Conversation(kind="chat", title="虚构") + session.add(conversation) + await session.flush() + turn = ConversationTurn(conversation_id=conversation.id, question="q", status="running") + session.add(turn) + await session.flush() + session.add(ChatTurnJob(id=turn.id, conversation_id=conversation.id, status="running")) + await session.commit() + turn_id = turn.id + turn_log.beat(turn_id, {"op": "heartbeat", "phase": "planning", "elapsed_ms": 3000}) + assert turn_log.latest_beat(turn_id)["phase"] == "planning" + async with SessionLocal() as session: + await turn_log.append(session, turn_id, "stage", {"stage": "run"}) + assert turn_log.latest_beat(turn_id) is None + turn_log.forget(turn_id) + + +async def _finished_turn(*, days_ago: float, status: str = "succeeded", events: int = 3) -> str: + from datetime import timedelta + + from app.db.base import utcnow + + async with SessionLocal() as session: + conversation = Conversation(kind="chat", title="虚构的旧会话") + session.add(conversation) + await session.flush() + turn = ConversationTurn(conversation_id=conversation.id, question="虚构的问题", answer="虚构的回答", + status="done" if status == "succeeded" else "error", meta={"outcome": "done"}) + session.add(turn) + await session.flush() + when = utcnow() - timedelta(days=days_ago) + session.add(ChatTurnJob(id=turn.id, conversation_id=conversation.id, status=status, last_seq=events, + finished_at=None if status in ("queued", "running") else when)) + for seq in range(1, events + 1): + session.add(ChatTurnEvent(turn_id=turn.id, seq=seq, type="op", ts=0.0, data={"op": "thinking"})) + await session.commit() + return turn.id + + +async def test_finished_turns_lose_their_event_log_after_the_retention_period(): + """已结束超过保留期的轮次整轮删事件日志:页面重开已结束的一轮读的是 conversation_turns 那一行(答案、图、复核、用量都在 + 那里),不读事件日志;任务行留着,last_seq 接着发号,之后再在这一轮上重试、继续运行不撞号。还在跑的、刚结束的不动。""" + from app.db.base import utcnow + + old = await _finished_turn(days_ago=turn_log.RETAIN_DAYS + 1) + stuck = await _finished_turn(days_ago=turn_log.RETAIN_DAYS + 1, status="interrupted") + recent = await _finished_turn(days_ago=1) + live = await _finished_turn(days_ago=turn_log.RETAIN_DAYS + 1, status="running") + purged = await turn_log.purge(utcnow()) + assert set(purged) >= {old, stuck} and not {recent, live} & set(purged) + assert await _events(old) == [] and await _events(stuck) == [] + assert len(await _events(recent)) == 3 and len(await _events(live)) == 3 + async with SessionLocal() as session: + job = await session.get(ChatTurnJob, old) + row = await session.get(ConversationTurn, old) + assert job is not None and job.last_seq == 3, "任务行留着:seq 接着往下发" + assert row.answer == "虚构的回答", "那一行不动" + async with SessionLocal() as session: + assert await turn_log.add(session, old, "turn.attempt", {"mode": "retry"}) == 4 + await session.rollback() + + +async def test_the_cleanup_runs_in_the_background_tick(monkeypatch): + """清理挂在调度循环的那一拍里(scheduler/loop.process_due),一小时最多清一次。""" + from app.db.base import utcnow + from app.scheduler import loop + + old = await _finished_turn(days_ago=turn_log.RETAIN_DAYS + 2) + monkeypatch.setattr(turn_log, "_last_purge", 0.0) + await loop.process_due(utcnow()) + assert await _events(old) == [] + again = await _finished_turn(days_ago=turn_log.RETAIN_DAYS + 2) + await loop.process_due(utcnow()) + assert len(await _events(again)) == 3, "一小时内不再清第二次" diff --git a/frontend/src/api/chatTurns.ts b/frontend/src/api/chatTurns.ts index e3990c9b..34bf8c3d 100644 --- a/frontend/src/api/chatTurns.ts +++ b/frontend/src/api/chatTurns.ts @@ -125,7 +125,12 @@ export function streamChatTurn( controller.abort() return } - if (typeof event.seq !== 'number' || event.seq <= lastSeq) continue // 重连时重叠的那段只要一份 + if (typeof event.seq !== 'number') { + // 助手等模型时的心跳不落库、不带 seq(服务端 chat/log.beat):照收、不动 lastSeq,重连时服务端会补最近的一拍 + if (event.type === 'op' && event.data?.op === 'heartbeat' && !closed) onEvent(event as ChatTurnEvent) + continue + } + if (event.seq <= lastSeq) continue // 重连时重叠的那段只要一份 lastSeq = event.seq got = true if (!closed) onEvent(event as ChatTurnEvent) diff --git a/frontend/src/store/chat.ts b/frontend/src/store/chat.ts index cabac70e..340eb304 100644 --- a/frontend/src/store/chat.ts +++ b/frontend/src/store/chat.ts @@ -1438,7 +1438,8 @@ function play(flow: Flow, serverId: string, after: number) { } return case 'op': - patch((t) => ({ ...opPatch(t, d as CopilotOp), turnSeq: e.seq })) + // 心跳不带 seq(不落库,chat/log.beat):不动收到的最后一个 seq + patch((t) => ({ ...opPatch(t, d as CopilotOp), ...(typeof e.seq === 'number' ? { turnSeq: e.seq } : {}) })) return case 'stage': { const stage = String(d.stage ?? '') diff --git a/scripts/check-chat.mjs b/scripts/check-chat.mjs index ddd6238d..f1d5ab23 100644 --- a/scripts/check-chat.mjs +++ b/scripts/check-chat.mjs @@ -1274,7 +1274,9 @@ async function driveTurn(T, epoch) { for (const op of got.ops) { if (op.gate) { await until(() => ctl[op.gate] || gone(T, epoch), 15000); continue } if (gone(T, epoch)) return - emit(T, 'op', op) + // 心跳不落库、不带 seq(服务端 chat/log.beat):只记最近的一拍,流上发一条不带 seq 的;之后落了新事件就作废 + if (op.op === 'heartbeat') T.beat = { op, at: T.events.length } + else emit(T, 'op', op) if (op.op !== 'thinking' && op.op !== 'heartbeat') ops.push(op) if (op.op === 'look') T.looks.push(op) if (op.op === 'usage') T.usage.copilot = addTotal(T.usage.copilot, op) @@ -1500,6 +1502,9 @@ async function streamTurn(route, T, after) { await until(() => T.events.length > after || !turnActive(T) || T.gen !== srv.gen, 6000) const fresh = T.events.filter((e) => e.seq > after) let body = fresh.map((e) => `data: ${JSON.stringify(e)}\n\n`).join('') + if (turnActive(T) && T.beat && T.beat.at === T.events.length) { + body += `data: ${JSON.stringify({ type: 'op', data: T.beat.op, ts: Date.now() / 1000 })}\n\n` + } if (!turnActive(T) && after + fresh.length >= T.events.length) { body += `data: ${JSON.stringify({ type: 'stream.end', status: T.job.status, data: { status: T.job.status } })}\n\n` } From fa431da91b0a11a60a2a0cf21296a06ae5ea52f8 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 19 Oct 2026 09:38:07 +0800 Subject: [PATCH 08/15] =?UTF-8?q?=E9=97=AE=E6=95=B0=E6=8D=AE=E6=8E=A5?= =?UTF-8?q?=E7=9D=80=E6=94=B6=E5=B0=BE=E4=B8=80=E8=BD=AE=E6=97=B6=E6=8C=89?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E8=B4=A6=E5=8F=B7=E9=87=8D=E6=96=B0=E6=A0=B8?= =?UTF-8?q?=E5=AF=B9=E8=BA=AB=E4=BB=BD=EF=BC=9A=E6=9C=8D=E5=8A=A1=E9=87=8D?= =?UTF-8?q?=E5=90=AF=E5=90=8E=E6=8E=A5=E6=89=8B=EF=BC=88=E5=B7=B2=E5=8F=91?= =?UTF-8?q?=E8=B5=B7=E8=BF=90=E8=A1=8C=E7=9A=84=E6=8E=A5=E7=9D=80=E7=AD=89?= =?UTF-8?q?=E3=80=81=E5=A4=8D=E6=A0=B8=EF=BC=89=E4=BB=A5=E5=89=8D=E7=94=A8?= =?UTF-8?q?=E7=9A=84=E6=98=AF=E5=8F=91=E8=B5=B7=E6=97=B6=E7=9A=84=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E5=BF=AB=E7=85=A7=EF=BC=8C=E4=B9=8B=E5=90=8E=E5=81=9C?= =?UTF-8?q?=E7=94=A8=E4=BA=86=E3=80=81=E9=99=8D=E4=BA=86=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E4=B8=8D=E4=BC=9A=E5=86=8D=E6=A0=B8=E5=AF=B9=E3=80=82=E7=8E=B0?= =?UTF-8?q?=E5=9C=A8=E6=AF=8F=E6=AC=A1=E6=89=A7=E8=A1=8C=E5=BC=80=E5=A7=8B?= =?UTF-8?q?=E6=97=B6=E6=8C=89=E7=94=A8=E6=88=B7=E5=90=8D=E8=AF=BB=E7=8E=B0?= =?UTF-8?q?=E5=9C=A8=E7=9A=84=E8=B4=A6=E5=8F=B7=EF=BC=88chat/job.current?= =?UTF-8?q?=5Fprincipal=EF=BC=89=EF=BC=9A=E4=B8=8D=E5=9C=A8=E4=BA=86?= =?UTF-8?q?=E3=80=81=E5=81=9C=E7=94=A8=E4=BA=86=EF=BC=8C=E6=88=96=E8=80=85?= =?UTF-8?q?=E6=8C=89=E7=8E=B0=E5=9C=A8=E7=9A=84=E8=A7=92=E8=89=B2=E5=B7=B2?= =?UTF-8?q?=E7=BB=8F=E7=94=A8=E4=B8=8D=E4=BA=86=E9=97=AE=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E7=9A=84=EF=BC=8C=E7=85=A7=E5=AE=9E=E6=A0=87=E4=B8=AD=E6=96=AD?= =?UTF-8?q?=E3=80=81=E5=86=99=E6=B8=85=E5=8E=9F=E5=9B=A0=EF=BC=88cause=20i?= =?UTF-8?q?dentity=EF=BC=89=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=BE=80=E4=B8=8B?= =?UTF-8?q?=E5=81=9A=EF=BC=9B=E8=BF=98=E7=94=A8=E5=BE=97=E4=BA=86=E7=9A=84?= =?UTF-8?q?=E6=8C=89=E7=8E=B0=E5=9C=A8=E7=9A=84=E8=A7=92=E8=89=B2=E3=80=81?= =?UTF-8?q?=E7=BB=84=E6=8E=A5=E7=9D=80=E5=81=9A=E3=80=82=E5=B7=B2=E7=BB=8F?= =?UTF-8?q?=E5=8F=91=E8=B5=B7=E7=9A=84=E8=BF=90=E8=A1=8C=E4=B8=8D=E5=8A=A8?= =?UTF-8?q?=E3=80=82=E9=A1=B5=E9=9D=A2=E7=85=A7=E5=8E=9F=E5=9B=A0=E8=AF=B4?= =?UTF-8?q?=E3=80=8C=E4=B8=8A=E6=AC=A1=E6=9C=AA=E5=AE=8C=E6=88=90=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/chat/job.py | 51 ++++++++++++++++-- backend/tests/test_chat_turns.py | 93 ++++++++++++++++++++++++++++++-- frontend/src/store/chat.ts | 8 +++ 3 files changed, 145 insertions(+), 7 deletions(-) diff --git a/backend/app/chat/job.py b/backend/app/chat/job.py index d993beab..b0c24e06 100644 --- a/backend/app/chat/job.py +++ b/backend/app/chat/job.py @@ -100,6 +100,34 @@ def principal_of(snapshot: Mapping[str, Any] | None) -> Any: mode=str(s.get("mode") or "off"), via=str(s.get("via") or "session")) +async def current_principal(snapshot: Mapping[str, Any] | None) -> tuple[Any, str | None]: + """发起时的身份快照 → 接手这一刻的身份:(身份, 不能接着做的原因)。 + + 关着登录时发起的(快照里 mode 是 off、没有用户名)照快照:没有账号可核对。开着登录的按用户名读现在的账号:不在了、 + 停用了交回原因;角色、组、显示名换成现在的(降了角色就按降了之后的做);按现在的身份已经用不了问数据(使用者、管理员 + 没开问数据,同 api/deps._chat_scope)也交回原因。认证方式、是否核实过照快照。""" + from app.api.deps import Principal + from app.auth import user_can_chat + from app.auth.sessions import groups_of + from app.db.models_identity import User + + s = dict(snapshot or {}) + name = s.get("username") + if not s or str(s.get("mode") or "off") == "off" or not name: + return principal_of(snapshot), None + async with SessionLocal() as session: + user = (await session.execute(select(User).where(User.username == name))).scalar_one_or_none() + if user is None or user.disabled: + return None, f"发起这一轮的账号「{name}」已{'停用' if user is not None else '不存在'},本轮没有接着收尾" + groups = await groups_of(session, user.id) + principal = Principal(username=user.username, role=user.role, display_name=user.display_name or user.username, + user_id=user.id, groups=groups, verified=bool(s.get("verified")), + mode=str(s.get("mode") or "off"), via=str(s.get("via") or "session")) + if not principal.at_least("builder") and not (principal.at_least("user") and user_can_chat()): + return None, f"发起这一轮的账号「{name}」已经没有问数据的权限,本轮没有接着收尾" + return principal, None + + # -------------------------------------------------------------------------- # 驱动:问数据和评测不一样的那几项 # -------------------------------------------------------------------------- @@ -362,8 +390,13 @@ class ChatTurnExecution(runtime.JobRun): self.request = dict(job.request or {}) self.turn_seq = row.seq self.run_id = job.run_id - # 这一轮按发起的人的身份跑:answer_ops 里的 restricted()、对话归属,发起运行时的遮罩、只读都看它 - set_principal(principal_of(job.principal)) + # 这一轮按发起的人的身份跑:answer_ops 里的 restricted()、对话归属,发起运行时的遮罩、只读都看它。按这一刻的账号重新核对 + # (current_principal):服务重启后接着收尾时,快照是发起那一刻的,之后停用了、降了角色的不能照快照接着做 + principal, refused = await current_principal(job.principal) + if refused is not None: + await self._identity_refused(refused) + return + set_principal(principal) scope = [s.get("id") for s in self.request.get("scope") or [] if isinstance(s, Mapping) and s.get("id")] driver = self.driver = ChatDriver(self, question=row.question, datasource_ids=scope) driver.turn.attempts = int(job.attempt or 0) @@ -427,6 +460,16 @@ class ChatTurnExecution(runtime.JobRun): await self._end("cancelled") await self._job(status="cancelled", stage=None, finished_at=utcnow()) + async def _identity_refused(self, reason: str) -> None: + """发起这一轮的账号接手时已经停用、或者已经没有问数据的权限:照实标中断、写清原因,不再往下做(不建图、不等运行、 + 不复核)。已经发起的那次运行不动——停在审批上的照旧等人,在记录页照常看得到;这一轮不替一个已经无权的人收它的尾。""" + await self._settle() + if not self.ended: + await self.save(status="error", error=reason, + meta={"cause": {"kind": "identity", "message": reason}, **_timing(self.request)}) + await self._end("error") + await self._job(status="interrupted", stage=None, error=reason, finished_at=utcnow()) + async def _failed(self, reason: str) -> None: await self._crash_out(reason) @@ -1166,5 +1209,5 @@ async def shutdown() -> None: __all__ = ["APPROVAL_POLL_S", "AUTO_RETRY_MODES", "BUSY", "CONCURRENCY_CLASS", "ChatDriver", "ChatTurnExecution", "MODES", "REVIEW_WAIT_S", "TURN_BUSY", "TURN_FINISHED", "TURN_INVALID", "TurnRefused", "cancel", "forget", - "job_view", "late_pending", "manager", "principal_of", "principal_snapshot", "shutdown", "start", "startup", - "state", "stop_conversation", "stream_status"] + "current_principal", "job_view", "late_pending", "manager", "principal_of", "principal_snapshot", "shutdown", + "start", "startup", "state", "stop_conversation", "stream_status"] diff --git a/backend/tests/test_chat_turns.py b/backend/tests/test_chat_turns.py index b0d2c12d..46d7066e 100644 --- a/backend/tests/test_chat_turns.py +++ b/backend/tests/test_chat_turns.py @@ -333,8 +333,9 @@ async def test_stop_while_running_cancels_the_run(client, monkeypatch, source): # -------------------------------------------------------------------------- -async def _stranded(conversation_id: str, *, run: Run | None, stage: str, mode: str = "ask") -> str: - """造一轮「上次进程没了时还在跑」的:那一行 running、任务 running(不在这个进程里)。""" +async def _stranded(conversation_id: str, *, run: Run | None, stage: str, mode: str = "ask", + principal: dict[str, Any] | None = None) -> str: + """造一轮「上次进程没了时还在跑」的:那一行 running、任务 running(不在这个进程里)。principal 是发起时的身份快照。""" async with SessionLocal() as session: if run is not None: session.add(run) @@ -344,7 +345,7 @@ async def _stranded(conversation_id: str, *, run: Run | None, stage: str, mode: session.add(turn) await session.flush() session.add(ChatTurnJob(id=turn.id, conversation_id=conversation_id, status="running", stage=stage, mode=mode, - run_id=run.id if run else None, request={"started_ms": 0}, principal={})) + run_id=run.id if run else None, request={"started_ms": 0}, principal=principal or {})) await session.commit() return turn.id @@ -1064,3 +1065,89 @@ async def test_the_cleanup_runs_in_the_background_tick(monkeypatch): again = await _finished_turn(days_ago=turn_log.RETAIN_DAYS + 2) await loop.process_due(utcnow()) assert len(await _events(again)) == 3, "一小时内不再清第二次" + + +# -------------------------------------------------------------------------- +# 服务重启后接着收尾:按接手时的账号重新核对 +# -------------------------------------------------------------------------- + + +async def _snapshot_of(name: str) -> dict[str, Any]: + from app.db.models_identity import User + + async with SessionLocal() as session: + user = (await session.execute(select(User).where(User.username == name))).scalar_one() + return {"username": user.username, "role": user.role, "display_name": user.display_name, "user_id": user.id, + "groups": [], "verified": True, "mode": "local", "via": "session"} + + +async def _set_user(name: str, **values: Any) -> None: + from sqlalchemy import update as sa_update + + from app.db.models_identity import User + + async with SessionLocal() as session: + await session.execute(sa_update(User).where(User.username == name).values(**values)) + await session.commit() + + +def _finished_run(by: str) -> Run: + return Run(status="succeeded", output={"答案": "华东 300。"}, graph={"nodes": [], "edges": []}, run_class="exploratory", + origin_kind="chat", started_by=by) + + +async def test_resuming_after_a_restart_checks_the_account_again(people, monkeypatch, source): + """服务重启后接着收尾一轮(已发起运行的接着等、复核),按接手这一刻的账号重新核对,不再照发起时的身份快照接着做: + 发起之后账号被停用的,照实标中断、写清原因;那一行不再往下改答案。""" + es.use_copilot(monkeypatch, es.Copilot(source["name"])) + es.use_report(monkeypatch) + mine = await _conversation(people["uma"], "uma 的对话") + turn_id = await _stranded(mine, run=_finished_run("uma"), stage="review", principal=await _snapshot_of("uma")) + await _set_user("uma", disabled=True) + await chat_job.startup(resume=True) + await chat_job.manager.wait(turn_id, timeout=30) + row = await _row(turn_id) + assert row.status == "error" and row.meta["cause"]["kind"] == "identity", row.meta + assert row.error == "发起这一轮的账号「uma」已停用,本轮没有接着收尾" and not row.answer + async with SessionLocal() as session: + assert (await session.get(ChatTurnJob, turn_id)).status == "interrupted" + assert (await _events(turn_id))[-1]["data"]["outcome"] == "error" + + +async def test_a_downgraded_account_without_chat_is_interrupted(people, monkeypatch, source): + """发起时是搭建者,之后被降成使用者、管理员又没给使用者开问数据:已经没有权限了,照实标中断。""" + es.use_copilot(monkeypatch, es.Copilot(source["name"])) + es.use_report(monkeypatch) + theirs = await _conversation(people["bob"], "bob 的对话") + turn_id = await _stranded(theirs, run=_finished_run("bob"), stage="review", principal=await _snapshot_of("bob")) + await _set_user("bob", role="user") + assert (await people["admin"].put("/api/auth/settings", json={"user_can_chat": False})).status_code == 200 + await chat_job.startup(resume=True) + await chat_job.manager.wait(turn_id, timeout=30) + row = await _row(turn_id) + assert row.status == "error" and row.meta["cause"]["kind"] == "identity" + assert row.error == "发起这一轮的账号「bob」已经没有问数据的权限,本轮没有接着收尾" + + +async def test_a_downgraded_account_that_can_still_chat_finishes_as_who_they_are_now(people, monkeypatch, source): + """降了角色、但仍然用得了问数据(使用者开着问数据):照常收尾,按现在的身份(角色、组)接着做,不按快照里的搭建者。""" + es.use_copilot(monkeypatch, es.Copilot(source["name"])) + es.use_report(monkeypatch) + seen: list[Any] = [] + real = chat_job.ChatDriver.drive + + async def spy(self, *a, **k): + from app.api.deps import current_principal + + seen.append(current_principal()) + return await real(self, *a, **k) + + monkeypatch.setattr(chat_job.ChatDriver, "drive", spy) + theirs = await _conversation(people["bob"], "bob 的对话") + turn_id = await _stranded(theirs, run=_finished_run("bob"), stage="review", principal=await _snapshot_of("bob")) + await _set_user("bob", role="user") + await chat_job.startup(resume=True) + await chat_job.manager.wait(turn_id, timeout=30) + row = await _row(turn_id) + assert row.status == "done" and row.answer == "华东 300。" + assert seen and seen[-1].username == "bob" and seen[-1].role == "user" diff --git a/frontend/src/store/chat.ts b/frontend/src/store/chat.ts index 340eb304..3f6e4a90 100644 --- a/frontend/src/store/chat.ts +++ b/frontend/src/store/chat.ts @@ -136,6 +136,8 @@ export type TurnCause = | { kind: 'run'; error?: string; detail?: string | null } | { kind: 'gone' } | { kind: 'build_interrupted' } + /** 服务重启后接着收尾时,发起这一轮的账号已停用、或者已经没有问数据的权限(服务端 chat/job.current_principal) */ + | { kind: 'identity'; message?: string } | { kind: 'crash'; message?: string } /** @@ -649,6 +651,12 @@ function causeFailure(cause: TurnCause, scope?: ScopeSource[]): Failure { return GONE case 'build_interrupted': return BUILD_INTERRUPTED + case 'identity': + return { + title: '上次未完成', + reason: cause.message || '发起这一轮的账号已无法接着收尾,本轮没有接着做。', + hint: '如需继续,请由现在有问数据权限的人重新提问。', + } case 'crash': return failureOf(cause.message || '发生错误') } From 358206312e233075f8659dc3f33e3fea8149b933 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 19 Oct 2026 09:46:47 +0800 Subject: [PATCH 09/15] =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E7=9A=84=E8=A7=84=E6=A8=A1=EF=BC=9A=E5=AE=9A=E6=97=B6=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=88=97=E8=A1=A8=E9=87=8C=E6=AF=8F=E4=B8=AA=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E8=A7=A6=E5=8F=91=E4=BB=BB=E5=8A=A1=E3=80=8C=E6=9C=89?= =?UTF-8?q?=E5=87=A0=E6=9D=A1=E5=9C=A8=E7=AD=89=E3=80=8D=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E6=9F=A5=E9=BD=90=EF=BC=88outbox.pending=5Ffor=5Fmany=EF=BC=9A?= =?UTF-8?q?=E5=BE=85=E6=B4=BE=E5=8F=91=E6=95=B0=E4=B8=80=E6=9D=A1=20SQL=20?= =?UTF-8?q?=E6=8C=89=E4=BB=BB=E5=8A=A1=E5=88=86=E7=BB=84=E7=AE=97=EF=BC=8C?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E8=A6=81=E7=9C=8B=E7=9A=84=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E4=B8=80=E6=AC=A1=E8=AF=BB=E5=87=BA=EF=BC=89=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=AF=8F=E8=A1=8C=E6=9F=A5=E4=B8=80=E6=AC=A1=EF=BC=9B?= =?UTF-8?q?=E6=AF=8F=E4=B8=80=E6=8B=8D=E6=B4=BE=E5=8F=91=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E6=9D=A1=E4=BB=B6=E7=9A=84=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E7=A7=8D=E7=B1=BB=E3=80=81=E5=AF=B9=E8=B1=A1=E5=AF=B9=E5=BE=97?= =?UTF-8?q?=E4=B8=8A=E7=9A=84=E4=BB=BB=E5=8A=A1=EF=BC=88SQLAlchemy=20?= =?UTF-8?q?=E7=9A=84=E9=80=9A=E7=94=A8=20JSON=20=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=EF=BC=89=EF=BC=8C=E4=BF=9D=E5=AD=98=E6=97=B6=E6=9F=A5=E7=8E=AF?= =?UTF-8?q?=E4=B8=80=E5=B1=82=E6=9F=A5=E4=B8=80=E6=AC=A1=E3=80=81=E5=8F=AA?= =?UTF-8?q?=E9=A1=BA=E7=9D=80=E3=80=8C=E4=B8=8A=E6=B8=B8=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E8=BF=90=E8=A1=8C=E6=88=90=E5=8A=9F=E7=BB=93=E6=9D=9F?= =?UTF-8?q?=E3=80=8D=E7=9A=84=E8=BE=B9=E6=89=BE=EF=BC=9B=E9=80=89=E5=AF=B9?= =?UTF-8?q?=E8=B1=A1=EF=BC=88trigger-options=EF=BC=89=E6=8C=89=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E3=80=81=E5=85=B3=E9=94=AE=E5=AD=97=E6=9F=A5=E3=80=81?= =?UTF-8?q?=E4=B8=80=E9=A1=B5=E6=9C=80=E5=A4=9A=20100=20=E4=B8=AA=E3=80=81?= =?UTF-8?q?more=20=E8=AF=B4=E8=BF=98=E6=9C=89=E6=B2=A1=E6=9C=89=EF=BC=8C?= =?UTF-8?q?=E5=BD=93=E5=89=8D=E9=80=89=E4=B8=AD=E7=9A=84=E6=80=BB=E5=9C=A8?= =?UTF-8?q?=E9=87=8C=E9=9D=A2=EF=BC=8C=E7=BC=96=E8=BE=91=E9=A1=B5=E4=B8=80?= =?UTF-8?q?=E9=A1=B5=E6=B2=A1=E5=88=97=E5=AE=8C=E6=97=B6=E5=A4=9A=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E6=8C=89=E5=90=8D=E7=A7=B0=E6=9F=A5=E6=89=BE=E3=80=82?= =?UTF-8?q?=E9=A1=BA=E5=B8=A6=E4=BF=AE=EF=BC=9A=E7=AD=89=E7=9D=80=E7=9A=84?= =?UTF-8?q?=E6=97=B6=E5=80=99=E4=BB=BB=E5=8A=A1=E6=94=B9=E4=BA=86=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E3=80=81=E6=9C=80=E6=97=A9=E9=82=A3=E6=9D=A1=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=90=88=E6=9D=A1=E4=BB=B6=E6=97=B6=EF=BC=8C=E3=80=8C?= =?UTF-8?q?=E4=BB=80=E4=B9=88=E6=97=B6=E5=80=99=E5=90=88=E6=88=90=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E3=80=8D=E6=8C=89=E5=89=A9=E4=B8=8B=E7=9A=84=E6=9C=80?= =?UTF-8?q?=E6=97=A9=E4=B8=80=E6=9D=A1=E7=AE=97=EF=BC=88=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E4=B8=8A=E7=9A=84=E6=97=B6=E5=88=BB=E5=92=8C=E7=9C=9F=E5=8F=91?= =?UTF-8?q?=E8=B5=B7=E7=9A=84=E4=B8=80=E8=87=B4=EF=BC=89=EF=BC=9B=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E7=AA=97=E5=8F=A3=E7=9A=84=E7=BB=93=E6=9D=9F=E6=97=B6?= =?UTF-8?q?=E5=88=BB=E5=92=8C=E8=BF=99=E4=B8=AA=E4=BB=BB=E5=8A=A1=E5=B7=B2?= =?UTF-8?q?=E6=9C=89=E7=9A=84=E5=8F=A6=E4=B8=80=E6=AC=A1=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E6=92=9E=E5=9C=A8=E5=90=8C=E4=B8=80=E5=88=BB=EF=BC=88=E5=88=AB?= =?UTF-8?q?=E7=9A=84=E6=9C=9F=E6=AC=A1=E6=97=A9=E4=B8=80=E6=8B=8D=E5=8F=91?= =?UTF-8?q?=E4=BA=86=EF=BC=89=E6=97=B6=E5=BE=80=E5=90=8E=E6=8C=AA=EF=BC=8C?= =?UTF-8?q?=E9=82=A3=E5=87=A0=E6=9D=A1=E4=BA=8B=E4=BB=B6=E7=85=A7=E5=B8=B8?= =?UTF-8?q?=E5=8F=91=E8=B5=B7=EF=BC=8C=E4=B8=8D=E5=86=8D=E8=A2=AB=E8=AE=B0?= =?UTF-8?q?=E5=88=B0=E5=B7=B2=E6=9C=89=E7=9A=84=E8=A7=A6=E5=8F=91=E4=B8=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/schedules.py | 82 ++++++--- backend/app/scheduler/outbox.py | 119 ++++++++++-- backend/app/scheduler/triggers.py | 55 +++--- backend/tests/test_schedule_events_scale.py | 171 ++++++++++++++++++ frontend/src/api/schedules.ts | 8 +- frontend/src/lib/termsTrigger.ts | 4 + .../src/pages/schedules/TriggerEditor.tsx | 41 ++++- frontend/src/types.ts | 2 + scripts/check-schedules.mjs | 35 ++++ 9 files changed, 451 insertions(+), 66 deletions(-) create mode 100644 backend/tests/test_schedule_events_scale.py diff --git a/backend/app/api/schedules.py b/backend/app/api/schedules.py index fe66843e..f924e9db 100644 --- a/backend/app/api/schedules.py +++ b/backend/app/api/schedules.py @@ -516,9 +516,9 @@ async def _schedule_outs(session: AsyncSession, rows: list[Schedule]) -> list[di from app import scheduler on = scheduler.scheduler_enabled() - for r in rows: - if _event_kind(r): - states[r.id] = {**await outbox.pending_for(session, r), "scheduler_on": on} + # 一次查齐(outbox.pending_for_many),不再每行各查一次 + waiting = await outbox.pending_for_many(session, [r for r in rows if _event_kind(r)]) + states = {sid: {**state, "scheduler_on": on} for sid, state in waiting.items()} def target(r: Schedule) -> dict[str, Any]: kind, target_id = service.target_of(r) @@ -592,36 +592,70 @@ async def list_variables(tz: str | None = Query(default=None, alias="timezone"), return service.variables(tz=zone, event=event) +#: 选对象时一页最多给几个(按名字排;更多的按关键字查) +TRIGGER_OPTIONS_LIMIT = 100 + + @router.get("/trigger-options") -async def trigger_options(session: AsyncSession = Depends(get_session)) -> dict[str, Any]: +async def trigger_options(event: str | None = None, q: str | None = None, selected: str | None = None, + limit: int = Query(default=TRIGGER_OPTIONS_LIMIT, ge=1, le=500), + session: AsyncSession = Depends(get_session)) -> dict[str, Any]: """新建、修改事件触发任务时能选的:每种事件的对象(能不能选、为什么不能,由这里判,界面不自己判)、「只认某种来源」的 来源、「只认某个应用」的应用、去抖的缺省值、触发链的上限。 - {events: [{event, label, subject_label, objects: [{id, name, ok, reason}]}], origins: [{kind, label}], + 对象按名字排、一页最多 limit 个,more 说后面还有没有:数据源、工作流成百上千时不一次全给,界面按关键字(q,名字里含它) + 再查。event 给了只查这一种事件的对象;selected 是当前选中的那一个(修改时),总在里面,不被分页挤掉、不显示成「已不存在」。 + + {events: [{event, label, subject_label, objects: [{id, name, ok, reason}], more}], origins: [{kind, label}], apps: [{id, name}], default_debounce_s, max_debounce_s, max_depth}""" from app.db.models import App, DataSource - sources = list((await session.execute(select(DataSource).order_by(DataSource.name))).scalars()) - flows = list((await session.execute(select(Workflow).where(Workflow.is_template.is_(False)) - .order_by(Workflow.name))).scalars()) - tables = [] - for src in sources: - if (src.origin or "manual") != "upload": - continue - ok = await triggers.accumulating(session, src) - tables.append({"id": src.id, "name": src.name, "ok": ok, - "reason": None if ok else "每次上传替换整张表,没有「新的一期」:要用这个事件请在导入设置里改为按期累积"}) - objects = { - "table.version": tables, - "workflow.finished": [{"id": w.id, "name": w.name, "ok": bool(w.published_version), - "reason": None if w.published_version else "还没有发布版本:只有正式运行成功结束才算事件"} - for w in flows], - "source.changed": [{"id": src.id, "name": src.name, "ok": True, "reason": None} for src in sources], - } + if event is not None and event not in triggers.EVENTS: + raise _invalid(f"不认识的事件种类:{event}", "event") + wanted = [event] if event else list(triggers.EVENTS) + words = (q or "").strip() + + async def page(model: Any, *conds: Any) -> tuple[list[Any], bool]: + query = select(model).where(*conds) + if words: + query = query.where(model.name.contains(words, autoescape=True)) + rows = list((await session.execute(query.order_by(model.name, model.id).limit(limit + 1))).scalars()) + more = len(rows) > limit + rows = rows[:limit] + if selected and all(r.id != selected for r in rows): + if (picked := (await session.execute(select(model).where(model.id == selected, *conds))).scalar_one_or_none()): + rows.append(picked) + return rows, more + + async def tables() -> tuple[list[dict[str, Any]], bool]: + # 只有上传表格有「新的一期」:按来源筛在库里做(老行没写来源的是手工接的源) + rows, more = await page(DataSource, DataSource.origin == "upload") + out = [] + for src in rows: + ok = await triggers.accumulating(session, src) + out.append({"id": src.id, "name": src.name, "ok": ok, + "reason": None if ok else "每次上传替换整张表,没有「新的一期」:要用这个事件请在导入设置里改为按期累积"}) + return out, more + + async def flows() -> tuple[list[dict[str, Any]], bool]: + rows, more = await page(Workflow, Workflow.is_template.is_(False)) + return [{"id": w.id, "name": w.name, "ok": bool(w.published_version), + "reason": None if w.published_version else "还没有发布版本:只有正式运行成功结束才算事件"} + for w in rows], more + + async def sources() -> tuple[list[dict[str, Any]], bool]: + rows, more = await page(DataSource) + return [{"id": src.id, "name": src.name, "ok": True, "reason": None} for src in rows], more + + makers = {"table.version": tables, "workflow.finished": flows, "source.changed": sources} + entries = [] + for e in wanted: + objects, more = await makers[e]() + entries.append({"event": e, "label": triggers.EVENT_LABEL[e], "subject_label": triggers.SUBJECT_LABEL[e], + "objects": objects, "more": more}) apps = (await session.execute(select(App.id, App.name).where(App.status != "archived").order_by(App.name))).all() return { - "events": [{"event": e, "label": triggers.EVENT_LABEL[e], "subject_label": triggers.SUBJECT_LABEL[e], - "objects": objects[e]} for e in triggers.EVENTS], + "events": entries, "origins": [{"kind": k, "label": triggers.ORIGIN_LABEL[k]} for k in triggers.FORMAL_ORIGINS], "apps": [{"id": i, "name": n} for i, n in apps], "default_debounce_s": triggers.DEFAULT_DEBOUNCE_S, "max_debounce_s": triggers.MAX_DEBOUNCE_S, diff --git a/backend/app/scheduler/outbox.py b/backend/app/scheduler/outbox.py index 366051a5..722147e0 100644 --- a/backend/app/scheduler/outbox.py +++ b/backend/app/scheduler/outbox.py @@ -273,12 +273,10 @@ async def route(now: datetime | None = None) -> int: .limit(ROUTE_BATCH))).scalars()) if not events: return 0 - rows = (await session.execute(select(Schedule.id, Schedule.trigger, Schedule.target_kind).where( - Schedule.trigger_kind == "event", Schedule.enabled.is_(True)))).all() - listeners = [(sid, trigger) for sid, trigger, kind in rows if (kind or "workflow") == "workflow"] + listening = await listeners(session, events) routed = 0 for ev in events: - matched = [sid for sid, trigger in listeners if triggers.matches(trigger, ev.event, ev.subject_id, ev.payload)] + matched = [sid for sid, trigger in listening if triggers.matches(trigger, ev.event, ev.subject_id, ev.payload)] try: async with SessionLocal() as session: # 条件 UPDATE 认领:两拍撞在一起(以后多开了进程)只有一拍改得动这一行 @@ -298,6 +296,28 @@ async def route(now: datetime | None = None) -> int: return routed +def _listening_to(pairs: Any) -> Any: + """触发条件监听着这几对(事件种类, 对象)的事件触发任务的 WHERE 条件:按触发条件里的事件种类和对象筛(SQLAlchemy 的通用 + JSON 访问,各库都能用),不把全部事件触发任务读出来。pairs 是 {事件种类: {对象 id…}}。""" + from sqlalchemy import and_, false, or_ + + conds = [and_(Schedule.trigger["event"].as_string() == kind, + Schedule.trigger[triggers.SUBJECT_KEY[kind]].as_string().in_(sorted(subjects))) + for kind, subjects in pairs.items() if kind in triggers.SUBJECT_KEY and subjects] + return or_(*conds) if conds else false() + + +async def listeners(session: Any, events: list[TriggerEvent]) -> list[tuple[str, dict[str, Any]]]: + """这几条事件可能派给的任务:启用着的、指向工作流的事件触发任务里,触发条件的事件种类和对象对得上的 [(任务 id, 触发条件)]。 + 过滤条件(应用、来源、期次……)由 triggers.matches 逐条再判。""" + pairs: dict[str, set[str]] = {} + for ev in events: + pairs.setdefault(ev.event, set()).add(ev.subject_id) + rows = (await session.execute(select(Schedule.id, Schedule.trigger, Schedule.target_kind).where( + Schedule.trigger_kind == "event", Schedule.enabled.is_(True), _listening_to(pairs)))).all() + return [(sid, trigger) for sid, trigger, kind in rows if (kind or "workflow") == "workflow"] + + async def fire_waiting(now: datetime | None = None) -> list[ScheduleFire]: """有等着的事件的任务,各把到点的去抖窗口处理完。""" now = now or utcnow() @@ -382,6 +402,7 @@ async def _drain(schedule_id: str, now: datetime) -> list[ScheduleFire]: missed = [w for w in due if (now - w.due).total_seconds() > limit] timely = [w for w in due if w not in missed] outputs = {id(w): await _outputs_of(session, w.events[-1]) for w in timely} + await _clear_of_earlier_fires(schedule_id, timely) out: list[ScheduleFire] = [] if missed: fire = await _skip_missed(schedule_id, missed, tz, grace) @@ -396,6 +417,32 @@ async def _drain(schedule_id: str, now: datetime) -> list[ScheduleFire]: return out +async def _clear_of_earlier_fires(schedule_id: str, wins: list[_Window]) -> None: + """要发起的窗口的结束时刻(fire_at)和这个任务已经有的另一次事件触发撞在同一刻(别的期次的窗口早一拍发了,结束时刻相同) + 时往后挪 1 微秒,直到不撞:service.fire_once 按 (任务, fire_at, kind) 认「已经处理过」,撞上了就交回已有的那一条,这个 + 窗口的几条事件会被记到别人的触发上、不再发起。已有的那次触发里有这个窗口的事件(两拍撞在一起处理了同一个窗口)的不挪: + 那就是同一次,交回已有的那一条是对的。""" + from app.scheduler import service + + if not wins: + return + lo, hi = min(w.due for w in wins), max(w.due for w in wins) + timedelta(seconds=1) + async with SessionLocal() as session: + rows = (await session.execute(select(ScheduleFire.fire_at, ScheduleFire.inputs).where( + ScheduleFire.schedule_id == schedule_id, ScheduleFire.kind == "event", + ScheduleFire.fire_at >= lo, ScheduleFire.fire_at <= hi))).all() + taken: dict[datetime, set[str]] = {} + for at, inputs in rows: + brief = (inputs or {}).get("event") if isinstance(inputs, dict) else None + ids = brief.get("event_ids") if isinstance(brief, dict) else None + taken.setdefault(service.as_utc(at), set()).update(str(i) for i in ids or []) + for w in sorted(wins, key=lambda w: w.due): + mine = {ev.id for ev in w.events} + while w.due in taken and not taken[w.due] & mine: + w.due += timedelta(microseconds=1) + taken.setdefault(w.due, set()).update(mine) + + def _event_fire(events: list[TriggerEvent], tz: str, outputs: dict[str, Any]) -> EventFire: """几条合成一次的事件:内容取最新的那一条,深度取最深的那一条加 1。""" latest = events[-1] @@ -491,17 +538,57 @@ async def _clean(now: datetime) -> None: async def pending_for(session: Any, schedule: Schedule) -> dict[str, Any]: - """一个事件触发任务现在有几条事件在等:events 还没派发的(调度关着时一直留在这里)、waiting 已经派给它、在去抖窗口里的; - due_at 是窗口结束、会合成一次触发的时刻(没有在等的为空)。""" - trigger = schedule.trigger if isinstance(schedule.trigger, dict) else {} - subject, event = triggers.subject_of(trigger), trigger.get("event") or "" - rows = (await session.execute(select(TriggerEvent.payload).where( - TriggerEvent.status == "pending", TriggerEvent.event == event, TriggerEvent.subject_id == subject) - .limit(ROUTE_BATCH))).scalars() if subject else [] - unrouted = sum(1 for payload in rows if triggers.matches(trigger, event, subject, payload)) - waiting, first = (await session.execute(select(func.count(), func.min(TriggerDelivery.occurred_at)).where( - TriggerDelivery.schedule_id == schedule.id, TriggerDelivery.status == "pending"))).one() + """一个事件触发任务现在有几条事件在等(见 pending_for_many)。""" + return (await pending_for_many(session, [schedule]))[schedule.id] + + +async def pending_for_many(session: Any, schedules: list[Schedule]) -> dict[str, dict[str, Any]]: + """几个事件触发任务各有几条事件在等,一次查齐(定时任务列表每行都要,不再每行各查一次):任务 id → {events, waiting, due_at}。 + + events 是还没派发的(调度关着时一直留在这里);waiting 是已经派给它、在去抖窗口里的;due_at 是最早那个窗口结束、会合成 + 一次触发的时刻(没有在等的为空)。等着的时候任务改了条件、已经不合条件的(下一拍会标 dropped、不再派发)不算:窗口按剩下的 + 最早一条起算,和派发时(_drain)切的窗口一样。待派发数按任务分组一条 SQL 算;窗口要看事件内容,等着的那几条一次读出来。""" from app.scheduler import service - due = service.as_utc(first) + timedelta(seconds=triggers.debounce_of(trigger)) if first is not None else None - return {"events": unrouted, "waiting": int(waiting or 0), "due_at": due.isoformat() if due else None} + rows = [s for s in schedules if s is not None] + out = {s.id: {"events": 0, "waiting": 0, "due_at": None} for s in rows} + if not rows: + return out + trig = {s.id: s.trigger if isinstance(s.trigger, dict) else {} for s in rows} + ids = list(out) + counts = dict((await session.execute(select(TriggerDelivery.schedule_id, func.count()).where( + TriggerDelivery.schedule_id.in_(ids), TriggerDelivery.status == "pending") + .group_by(TriggerDelivery.schedule_id))).all()) + waiting = (await session.execute( + select(TriggerDelivery.schedule_id, TriggerDelivery.id, TriggerDelivery.created_at, TriggerEvent) + .join(TriggerEvent, TriggerEvent.id == TriggerDelivery.event_id) + .where(TriggerDelivery.schedule_id.in_([i for i in ids if counts.get(i)]), TriggerDelivery.status == "pending") + .order_by(TriggerDelivery.occurred_at, TriggerEvent.created_at).limit(DRAIN_BATCH))).all() if counts else [] + by: dict[str, list[tuple[str, TriggerEvent, datetime]]] = {} + stale: dict[str, int] = {} + for sid, did, routed, ev in waiting: + if triggers.matches(trig[sid], ev.event, ev.subject_id, ev.payload): + by.setdefault(sid, []).append((did, ev, service.as_utc(routed))) + else: + stale[sid] = stale.get(sid, 0) + 1 + for sid in ids: + out[sid]["waiting"] = max(int(counts.get(sid) or 0) - stale.get(sid, 0), 0) + wins = windows(by.get(sid, []), triggers.debounce_of(trig[sid])) + if wins: + out[sid]["due_at"] = min(w.due for w in wins).isoformat() + pairs: dict[str, set[str]] = {} + for sid in ids: + if subject := triggers.subject_of(trig[sid]): + pairs.setdefault(str(trig[sid].get("event") or ""), set()).add(subject) + if pairs: + from sqlalchemy import and_, or_ + + cond = or_(*[and_(TriggerEvent.event == kind, TriggerEvent.subject_id.in_(sorted(subs))) + for kind, subs in pairs.items()]) + unrouted = (await session.execute(select(TriggerEvent.event, TriggerEvent.subject_id, TriggerEvent.payload) + .where(TriggerEvent.status == "pending", cond).limit(ROUTE_BATCH))).all() + for kind, subject, payload in unrouted: + for sid in ids: + if triggers.matches(trig[sid], kind, subject, payload): + out[sid]["events"] += 1 + return out diff --git a/backend/app/scheduler/triggers.py b/backend/app/scheduler/triggers.py index 1a667cf3..00ad81a5 100644 --- a/backend/app/scheduler/triggers.py +++ b/backend/app/scheduler/triggers.py @@ -248,29 +248,40 @@ async def find_cycle(session: Any, *, schedule_id: str | None, trigger: dict[str start, target = subject_of(trigger), target_workflow_id if start == target: return [start, start] - edges: dict[str, set[str]] = {} - rows = (await session.execute(select(Schedule.id, Schedule.trigger, Schedule.workflow_id, Schedule.target_kind) - .where(Schedule.trigger_kind == "event"))).all() - for sid, other, workflow_id, kind in rows: - if sid == schedule_id or (kind or "workflow") != "workflow" or not isinstance(other, dict): - continue - if other.get("event") == "workflow.finished" and subject_of(other) and workflow_id: - edges.setdefault(subject_of(other), set()).add(workflow_id) - # 从 B 出发找回 A 的路(广度优先,取最短的一条说给人听) - parent: dict[str, str | None] = {target: None} - queue = [target] - while queue: - node = queue.pop(0) - for nxt in sorted(edges.get(node, ())): - if nxt in parent: + + async def edges_from(nodes: list[str]) -> dict[str, set[str]]: + """这几个工作流跑完会触发哪些工作流:只读「上游工作流运行成功结束」、对象是它们的那几个任务(按触发条件里的事件种类和 + 对象筛,SQLAlchemy 的通用 JSON 访问),不把全部事件触发任务读出来。""" + rows = (await session.execute(select(Schedule.id, Schedule.trigger, Schedule.workflow_id, Schedule.target_kind) + .where(Schedule.trigger_kind == "event", + Schedule.trigger["event"].as_string() == "workflow.finished", + Schedule.trigger["workflow_id"].as_string().in_(nodes)))).all() + out: dict[str, set[str]] = {} + for sid, other, workflow_id, kind in rows: + if sid == schedule_id or (kind or "workflow") != "workflow" or not isinstance(other, dict): continue - parent[nxt] = node - if nxt == start: - back = [start] # A ← … ← B,倒着记 - while back[-1] != target: - back.append(parent[back[-1]] or target) - return [start, *reversed(back)] - queue.append(nxt) + if other.get("event") == "workflow.finished" and subject_of(other) and workflow_id: + out.setdefault(subject_of(other), set()).add(workflow_id) + return out + + # 从 B 出发找回 A 的路(广度优先,一层查一次,取最短的一条说给人听) + parent: dict[str, str | None] = {target: None} + frontier = [target] + while frontier: + edges = await edges_from(frontier) + nxt_level: list[str] = [] + for node in frontier: + for nxt in sorted(edges.get(node, ())): + if nxt in parent: + continue + parent[nxt] = node + if nxt == start: + back = [start] # A ← … ← B,倒着记 + while back[-1] != target: + back.append(parent[back[-1]] or target) + return [start, *reversed(back)] + nxt_level.append(nxt) + frontier = nxt_level return None diff --git a/backend/tests/test_schedule_events_scale.py b/backend/tests/test_schedule_events_scale.py new file mode 100644 index 00000000..547701a0 --- /dev/null +++ b/backend/tests/test_schedule_events_scale.py @@ -0,0 +1,171 @@ +"""事件触发的规模(scheduler/outbox.py、scheduler/triggers.py、api/schedules.py): + +- 定时任务列表里每个事件触发任务「有几条在等」一次查齐(outbox.pending_for_many:一条 SQL 按任务分组算待派发数),不再 + 每行各查一次; +- 每一拍派发只读监听着这几种事件、这几个对象的任务(按触发条件里的事件种类、对象筛,用 SQLAlchemy 的通用 JSON 访问), + 不再把全部事件触发任务读出来;保存时查环只顺着「上游工作流运行成功结束」那几条边往下找; +- 选对象(trigger-options)按事件、关键字查、分页:返回 more 说还有没有,当前选中的那一个总在里面; +- 去抖窗口最早那一条因为任务改了条件被标 dropped 时,「什么时候合成触发」按剩下的最早一条算;一个窗口的结束时刻和这个任务 + 已经有的另一次触发撞在同一刻(别的期次、早一拍发的)时往后挪,那几条事件照常发起,不并进已有的触发。 +工作流、表格、数据源都是虚构的。 +""" +from __future__ import annotations + +from contextlib import contextmanager +from datetime import timedelta + +import pytest +from httpx import ASGITransport, AsyncClient +from sqlalchemy import event as sa_event +from sqlalchemy import select + +from app.db.base import SessionLocal, engine, utcnow +from app.db.models import Schedule, ScheduleFire, Workflow +from app.db.models_triggers import TriggerDelivery +from app.scheduler import outbox +from tests.test_schedule_events import ( # noqa: F401 - engine_up 是本模块也要的 autouse 夹具 + deliveries, engine_up, event_schedule, fires, put_event, settle, workflow, +) + + +@contextmanager +def counted(): + """数这一段里发了几条 SQL。""" + seen: list[str] = [] + + def hook(conn, cursor, statement, *_a): # noqa: ANN001 + seen.append(statement) + + target = engine.sync_engine + sa_event.listen(target, "before_cursor_execute", hook) + try: + yield seen + finally: + sa_event.remove(target, "before_cursor_execute", hook) + + +@pytest.fixture +async def client(): + from app.main import app + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c + + +async def _many(n: int) -> tuple[str, list[str]]: + upstream = await workflow("门店日报") + downstream = await workflow("区域汇总") + ids = [await event_schedule(downstream, {"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 600}, + name=f"汇总{i}") for i in range(n)] + return upstream, ids + + +async def test_waiting_counts_for_many_schedules_in_one_go(): + upstream, ids = await _many(3) + t0 = utcnow().replace(microsecond=0) + for i in range(2): + await put_event("workflow.finished", upstream, {"workflow_id": upstream, "run_id": f"r{i}"}, t0 + timedelta(seconds=i)) + await outbox.route(t0 + timedelta(seconds=5)) + await put_event("workflow.finished", upstream, {"workflow_id": upstream, "run_id": "r9"}, t0 + timedelta(seconds=9)) + async with SessionLocal() as session: + rows = list((await session.execute(select(Schedule).where(Schedule.id.in_(ids)))).scalars()) + one = {r.id: await outbox.pending_for(session, r) for r in rows} + with counted() as sql: + many = await outbox.pending_for_many(session, rows) + assert many == one + assert all(v == {"events": 1, "waiting": 2, "due_at": (t0 + timedelta(seconds=600)).isoformat()} for v in many.values()) + assert len(sql) <= 3, sql + + +async def test_the_schedule_list_does_not_query_once_per_row(client): + await _many(2) + with counted() as few: + assert (await client.get("/api/schedules")).status_code == 200 + await _many(5) + with counted() as more: + assert (await client.get("/api/schedules")).status_code == 200 + assert len(more) <= len(few), (len(few), len(more)) + + +async def test_a_tick_reads_only_the_schedules_listening_to_the_pending_events(): + upstream, ids = await _many(2) + other = await workflow("别的日报") + elsewhere = await event_schedule(await workflow("别的汇总"), {"event": "workflow.finished", "workflow_id": other}) + await put_event("workflow.finished", upstream, {"workflow_id": upstream, "run_id": "r1"}, utcnow()) + async with SessionLocal() as session: + from app.db.models_triggers import TriggerEvent + + pending = list((await session.execute(select(TriggerEvent).where(TriggerEvent.status == "pending"))).scalars()) + listeners = await outbox.listeners(session, pending) + assert sorted(sid for sid, _ in listeners) == sorted(ids) and elsewhere not in {sid for sid, _ in listeners} + + +async def test_trigger_options_search_and_page(client): + names = [f"检索样例{c}-{i}" for c in "甲乙" for i in range(3)] + async with SessionLocal() as session: + rows = [Workflow(name=n, graph={"nodes": [], "edges": []}) for n in names] + session.add_all(rows) + await session.commit() + chosen = next(r.id for r in rows if r.name == "检索样例乙-2") + r = await client.get("/api/schedules/trigger-options", params={"event": "workflow.finished", "q": "检索样例甲", + "limit": 2}) + assert r.status_code == 200, r.text + [entry] = r.json()["events"] + assert entry["event"] == "workflow.finished" and entry["more"] is True + assert [o["name"] for o in entry["objects"]] == ["检索样例甲-0", "检索样例甲-1"] + r = await client.get("/api/schedules/trigger-options", params={"event": "workflow.finished", "q": "检索样例甲", + "limit": 2, "selected": chosen}) + [entry] = r.json()["events"] + assert chosen in [o["id"] for o in entry["objects"]], "当前选中的那一个总在里面(不被分页挤掉、不显示成已不存在)" + # 不带参数:照旧给全部种类,每种最多一页 + body = (await client.get("/api/schedules/trigger-options")).json() + assert {e["event"] for e in body["events"]} == {"table.version", "workflow.finished", "source.changed"} + assert all("more" in e for e in body["events"]) + + +async def test_a_dropped_earliest_event_no_longer_sets_the_window_end(): + """窗口最早那一条因为任务改了条件被标 dropped:什么时候合成触发按剩下的最早一条算(列表上的 due_at、真发起的 fire_at 一致)。""" + upstream = await workflow("门店日报") + downstream = await workflow("区域汇总") + sid = await event_schedule(downstream, {"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 60}) + t0 = utcnow().replace(microsecond=0) + await put_event("workflow.finished", upstream, {"workflow_id": upstream, "run_id": "r1", "origin_kind": "launcher"}, t0) + e2 = await put_event("workflow.finished", upstream, {"workflow_id": upstream, "run_id": "r2", + "origin_kind": "app_entry"}, t0 + timedelta(seconds=30)) + await outbox.route(t0 + timedelta(seconds=31)) + async with SessionLocal() as session: + await session.execute(Schedule.__table__.update().where(Schedule.id == sid).values( + trigger={"event": "workflow.finished", "workflow_id": upstream, "debounce_s": 60, "origin_kind": "app_entry"})) + await session.commit() + state = await outbox.pending_for(session, await session.get(Schedule, sid)) + assert state["due_at"] == (t0 + timedelta(seconds=90)).isoformat(), state + assert await outbox.dispatch_due(t0 + timedelta(seconds=61)) == [] + [fire] = await outbox.dispatch_due(t0 + timedelta(seconds=91)) + assert fire.fire_at == t0 + timedelta(seconds=90) and fire.inputs["event"]["event_ids"] == [e2] + await settle(fire.run_id) + + +async def test_a_window_ending_where_another_fire_already_is_still_fires(): + """一个窗口的结束时刻和这个任务已有的另一次触发撞在同一刻(别的期次早一拍发了):往后挪,这几条照常发起, + 不并进已有的那次触发、不被它吞掉。""" + source = "src-虚构" + downstream = await workflow("月报") + sid = await event_schedule(downstream, {"event": "table.version", "source_id": source, "debounce_s": 60}) + t0 = utcnow().replace(microsecond=0) + aug = await put_event("table.version", source, {"period": "2026-08", "table": "日报"}, t0) + [first] = await outbox.dispatch_due(t0 + timedelta(seconds=61)) + assert first.fire_at == t0 + timedelta(seconds=60) and first.inputs["event"]["event_ids"] == [aug] + await settle(first.run_id) + # 另一期的事件发生在同一刻,晚到了一拍(比如补写的):窗口结束同样是 t0+60 + sep = await put_event("table.version", source, {"period": "2026-09", "table": "日报"}, t0) + [second] = await outbox.dispatch_due(t0 + timedelta(seconds=62)) + assert second.id != first.id and second.inputs["event"]["event_ids"] == [sep] + assert second.fire_at > first.fire_at + states = {d.event_id: (d.status, d.fire_id) for d in await deliveries(sid)} + assert states[sep] == ("fired", second.id) + await settle(second.run_id) + async with SessionLocal() as session: + assert len(list((await session.execute(select(ScheduleFire).where(ScheduleFire.schedule_id == sid))).scalars())) == 2 + assert len(await fires(sid)) == 2 + assert [d.status for d in await deliveries(sid)] == ["fired", "fired"] + assert TriggerDelivery is not None diff --git a/frontend/src/api/schedules.ts b/frontend/src/api/schedules.ts index d92e3015..98faad1f 100644 --- a/frontend/src/api/schedules.ts +++ b/frontend/src/api/schedules.ts @@ -36,8 +36,12 @@ export const schedules = { */ eventVariables: (event: ScheduleEventKind, opts?: RequestOptions) => get(`/schedules/variables${qs({ trigger_kind: 'event', event })}`, opts), - /** 新建、修改事件触发任务时能选的对象(能不能选、为什么由服务端判)、来源、应用。老服务端没有,回 404 */ - triggerOptions: (opts?: RequestOptions) => get('/schedules/trigger-options', opts), + /** + * 新建、修改事件触发任务时能选的对象(能不能选、为什么由服务端判)、来源、应用。老服务端没有,回 404。对象一页有上限 + * (more 为真时没给完):按事件种类、名字里的关键字再查;selected 是当前选中的那一个,总在结果里 + */ + triggerOptions: (find?: { event?: ScheduleEventKind; q?: string; selected?: string }, opts?: RequestOptions) => + get(`/schedules/trigger-options${qs(find)}`, opts), /** 立即运行一次:记一条手动触发,上一次还没结束时照样按重叠策略跳过 */ run: (id: string) => post(`${at(id)}/run`), /** 触发历史,新的在前。before 是上一页给的 next_before */ diff --git a/frontend/src/lib/termsTrigger.ts b/frontend/src/lib/termsTrigger.ts index 2a94ad32..caeff647 100644 --- a/frontend/src/lib/termsTrigger.ts +++ b/frontend/src/lib/termsTrigger.ts @@ -43,6 +43,10 @@ export const TRIGGER_TEXT = { /** 不能选的对象:名字后面接服务端给的原因 */ subjectNotOk: (name: string, reason: string) => `${name}(${reason})`, subjectGone: (name: string) => `${name}(已不存在)`, + /** 对象太多、一页没列完:按名字查找 */ + subjectSearch: (label: string) => `按名称查找${label}`, + subjectSearchHint: '只列出了一部分,输入名称中的字查找', + subjectSearchNone: '没有名称中含这些字的', optionsLoading: '正在读取可选的对象…', optionsFailed: '无法读取可选的对象', optionsUnsupported: '服务端暂不支持事件触发,请升级服务端后再使用', diff --git a/frontend/src/pages/schedules/TriggerEditor.tsx b/frontend/src/pages/schedules/TriggerEditor.tsx index d36f131b..b307e704 100644 --- a/frontend/src/pages/schedules/TriggerEditor.tsx +++ b/frontend/src/pages/schedules/TriggerEditor.tsx @@ -73,6 +73,28 @@ function useTriggerOptions(): OptionsState { return state } +/** + * 对象太多、一页没列完(服务端的 more)时按名字查:输入停一下再查,结果只换这一种事件的对象;当前选中的那一个总带上 + * (selected),不被分页挤掉、不显示成已不存在 + */ +function useObjectSearch(kind: ScheduleEventKind, selected: string, enabled: boolean) { + const [q, setQ] = useState('') + const [found, setFound] = useState<{ key: string; objects: ScheduleTriggerOptions['events'][number]['objects'] } | null>(null) + const words = q.trim() + const key = `${kind}\u0000${words}` + useEffect(() => { + if (!enabled || !words) return + const controller = new AbortController() + const timer = setTimeout(() => { + api.schedules.triggerOptions({ event: kind, q: words, selected: selected || undefined }, { signal: controller.signal }) + .then((data) => setFound({ key, objects: data.events.find((e) => e.event === kind)?.objects ?? [] })) + .catch(() => {}) + }, 300) + return () => { clearTimeout(timer); controller.abort() } + }, [enabled, kind, words, selected, key]) + return { q, setQ, objects: words && found?.key === key ? found.objects : null } +} + export function EventSection({ value, onChange, timezone, setTimezone, savedName, error, debounceError }: { value: TriggerForm onChange: (t: TriggerForm) => void @@ -91,7 +113,9 @@ export function EventSection({ value, onChange, timezone, setTimezone, savedName const kind = value.event const label = TT.subject[kind] const entry = options.status === 'ok' ? options.data.events.find((e) => e.event === kind) : undefined - const objects = entry?.objects ?? [] + const paged = !!entry?.more + const search = useObjectSearch(kind, value.subjectId, paged) + const objects = search.objects ?? entry?.objects ?? [] const listed = objects.some((o) => o.id === value.subjectId) let picker @@ -112,7 +136,10 @@ export function EventSection({ value, onChange, timezone, setTimezone, savedName aria-describedby={error ? 'schedule-trigger-subject-error' : undefined} onChange={(e) => set({ subjectId: e.target.value })}> - {value.subjectId && !listed && } + {/* 一页没列完时不在列表里不等于不存在:写保存时记下的名字,不标「已不存在」 */} + {value.subjectId && !listed && ( + + )} {objects.map((o) => (
+ {paged && ( + search.setQ(e.target.value)} /> + )} {picker} + {paged && ( +

+ {search.objects && !search.objects.length ? TT.subjectSearchNone : TT.subjectSearchHint} +

+ )}
{error && ( )} + {probes.length > 0 && ( +
+

{T.probesTitle}

+
    + {probes.map((p) => ( +
  • + {T.probeSkipped(p.name, p.version, p.missing ?? [], p.reason)} + {p.inputs_from && T.probeFrom[p.inputs_from] && ( + {T.probeFrom[p.inputs_from]} + )} +
  • + ))} +
+
+ )} ) } diff --git a/frontend/src/typesSource.ts b/frontend/src/typesSource.ts index 0317fc2e..8eec3417 100644 --- a/frontend/src/typesSource.ts +++ b/frontend/src/typesSource.ts @@ -54,6 +54,22 @@ export interface SourceChangeAffects { templates: SourceChangeTemplate[] /** 有效记录条件用到了这一列 */ rules_touched: boolean + /** + * 表结构变了之后口径没能试算的那几版(服务端 ledger/recall._probe_skipped):缺哪几个参数、原因原话、参数本来从哪来。 + * 只记没试算的;试算了的结论在召回待办里 + */ + probes?: SourceChangeProbe[] +} + +export interface SourceChangeProbe { + workflow_id: string + version: number + name: string + outcome: 'skipped' + missing: string[] + reason: string + /** formal 最近一次正式运行 / answer 等价的问数回答 / defaults 入口的缺省值 */ + inputs_from?: string } /** 现在能做的一个处理动作;ok 为假时 reason 写为什么做不了 */ diff --git a/scripts/check-catalog-changes.mjs b/scripts/check-catalog-changes.mjs index 751e7bd7..ff46c5e4 100644 --- a/scripts/check-catalog-changes.mjs +++ b/scripts/check-catalog-changes.mjs @@ -178,6 +178,9 @@ function freshChanges() { templates: [{ workflow_id: 'wf-check-weekly', name: '门店周报', version: 3, level: 'published', impact: 'direct', nodes: [{ node_id: 'q_orders', label: '取订单', type: 'tool', impact: 'direct', breaks: true, unknown_columns: ['status'] }] }], rules_touched: true, + // 表结构变了之后没能试算的口径(服务端 ledger/recall._probe_skipped):缺哪几个参数 + probes: [{ workflow_id: 'wf-check-weekly', version: 3, name: '门店周报', outcome: 'skipped', missing: ['start', 'end'], + reason: '「start」必填;「end」必填', inputs_from: 'defaults' }], }, actions: [act('clean_catalog'), act('redraft'), act('reprofile', NOT_PROFILED), ...COMMON] }), change({ seq: 14, id: 'chg-codes', table: 'orders', column: 'channel', kind: 'codes_new', category: 'value', severity: 'action', @@ -458,6 +461,8 @@ await section('变化:依据、前后、影响面、动作照服务端给的 check('……写死这张表的已发布模板:「这条 SQL 现在会报列不存在(status)」', (await textOf(d.locator('[data-change-node="q_orders"]'))).includes('这条 SQL 现在会报列不存在(status)') && (await d.locator('[data-change-node="q_orders"]').getAttribute('data-change-breaks')) === '1') check('……模板链到编排页那个节点', (await d.locator('[data-change-template] a').first().getAttribute('href')) === '/studio/wf-check-weekly?focus=q_orders') + check('……没能试算的口径照实写缺哪几个参数', (await textOf(d.locator('[data-change-probe="wf-check-weekly"]'))) + .includes('「门店周报」v3:缺少参数 start、end,没有试算')) check('……有效记录条件用到了这一列', (await textOf(d.locator('[data-change-rules]'))).includes('有效记录条件用到了这一列')) check('动作照服务端给的:清理目录项、重新起草、重新剖析、知道了、误报', JSON.stringify(await attrs(d.locator('[data-change-action]'), 'data-change-action')) === '["clean_catalog","redraft","reprofile","acknowledge","dismiss"]') From 14001c090004ff5181dc5e69e7b9f835210ba341 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 19 Oct 2026 10:13:04 +0800 Subject: [PATCH 14/15] =?UTF-8?q?=E8=AE=B0=E5=BD=95=E9=A1=B5=E5=AE=A1?= =?UTF-8?q?=E6=89=B9=E4=B9=8B=E5=90=8E=E7=9A=84=E6=A8=AA=E5=B9=85=E6=8C=89?= =?UTF-8?q?=E9=A9=B3=E5=9B=9E=E4=B9=8B=E5=90=8E=E5=8E=BB=E5=93=AA=E8=AF=B4?= =?UTF-8?q?=EF=BC=9A=E6=8E=A5=E4=BA=86=E9=A9=B3=E5=9B=9E=E5=87=BA=E5=8F=A3?= =?UTF-8?q?=E7=9A=84=E4=B8=8D=E8=AF=B4=E3=80=8C=E6=9C=AC=E6=AC=A1=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E7=BB=88=E6=AD=A2=E3=80=8D=EF=BC=8C=E6=B2=A1=E6=8E=A5?= =?UTF-8?q?=E9=A9=B3=E5=9B=9E=E5=87=BA=E5=8F=A3=E7=9A=84=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E3=80=81=E4=BB=A3=E7=A0=81=E5=AE=A1=E6=89=B9=E7=85=A7=E5=AE=9E?= =?UTF-8?q?=E8=AF=B4=E7=BB=88=E6=AD=A2=EF=BC=88=E6=8C=89=E4=B8=AD=E6=96=AD?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E7=9A=84=20reject=5Froute=EF=BC=9B=E8=80=81?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E7=85=A7=E6=97=A7=E6=8C=89=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/runs/RunDetailView.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/runs/RunDetailView.tsx b/frontend/src/pages/runs/RunDetailView.tsx index f6e79d55..7d0d50a4 100644 --- a/frontend/src/pages/runs/RunDetailView.tsx +++ b/frontend/src/pages/runs/RunDetailView.tsx @@ -21,6 +21,7 @@ import { signerNow } from '../../store/auth' import { explainRunError, explainStartError } from '../../lib/explain' import { canLeave, leavePass } from '../../lib/leave' import { SIGNER_TEXT, UNSAVED_HINT, isUnsaved, runName } from '../../lib/terms' +import { rejectRouteOf } from '../../lib/termsApp' import { asView, duplicateNames, graphShape, idTail, isLiveRun, runScope, type DetailView, } from './model' @@ -347,7 +348,11 @@ export function RunDetailView({ runId, onChange, onDeleted }: { setFeedback({ kind: 'decided', approved, node: a.node_label ?? labelOf(a.node_id) ?? a.node_id, by: done?.resolved_by, at: done?.resolved_at ?? new Date().toISOString(), - terminates: approved === false && a.mode !== 'approve', + // 驳回之后去哪按中断内容的 reject_route(接了驳回出口的不终止,没接的工具 / 代码审批会终止);老审批没带的照旧按模式猜 + terminates: approved === false && (() => { + const route = rejectRouteOf(a.payload as Record | undefined) + return route ? 'ends' in route && route.ends === 'run' : a.mode !== 'approve' + })(), }) // 批完要能看着它往下跑:接上实时流,时间线继续长 d.follow(next) From 76290baaf18558959544cabd338f043afc61b0ce Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Mon, 19 Oct 2026 10:41:06 +0800 Subject: [PATCH 15/15] =?UTF-8?q?=E9=97=AE=E6=95=B0=E6=8D=AE=E7=9A=84?= =?UTF-8?q?=E5=BF=83=E8=B7=B3=E5=8F=AA=E8=90=BD=E6=8D=A2=E4=BA=86=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E7=9A=84=E9=82=A3=E4=B8=80=E6=8B=8D=EF=BC=9A=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E6=A3=80=E6=9F=A5=E5=8F=91=E7=8E=B0=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E5=90=8E=E8=BF=87=E7=A8=8B=E9=87=8C=E3=80=8C=E8=A7=84?= =?UTF-8?q?=E5=88=92=E3=80=8D=E9=82=A3=E4=B8=80=E8=A1=8C=E6=B2=A1=E4=BA=86?= =?UTF-8?q?=E2=80=94=E2=80=94=E9=A1=B5=E9=9D=A2=E6=8C=89=E5=BF=83=E8=B7=B3?= =?UTF-8?q?=E7=9A=84=E9=98=B6=E6=AE=B5=E7=94=BB=E9=82=A3=E4=B8=80=E8=A1=8C?= =?UTF-8?q?=EF=BC=8C=E5=85=A8=E9=83=BD=E4=B8=8D=E8=90=BD=E5=BA=93=E5=B0=B1?= =?UTF-8?q?=E5=9B=9E=E6=94=BE=E4=B8=8D=E5=87=BA=E6=9D=A5=E3=80=82=E7=8E=B0?= =?UTF-8?q?=E5=9C=A8=E6=8D=A2=E4=BA=86=E9=98=B6=E6=AE=B5=E7=9A=84=E9=82=A3?= =?UTF-8?q?=E4=B8=80=E6=8B=8D=EF=BC=88=E8=A7=84=E5=88=92=20=E2=86=92=20?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B=20=E2=86=92=20=E6=90=AD=E5=BB=BA=E2=80=A6?= =?UTF-8?q?=E2=80=A6=EF=BC=89=E7=85=A7=E8=90=BD=EF=BC=8C=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E6=8E=A5=E7=9D=80=E7=9A=84=E4=BF=9D=E6=B4=BB?= =?UTF-8?q?=E5=BF=83=E8=B7=B3=E6=89=8D=E5=8F=AA=E5=9C=A8=E6=B5=81=E4=B8=8A?= =?UTF-8?q?=E5=8F=91=E3=80=81=E4=B8=8D=E8=90=BD=E5=BA=93=EF=BC=9B=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E6=8E=A5=E7=9D=80=E7=9A=84=E5=BF=83=E8=B7=B3=E9=A1=B6?= =?UTF-8?q?=E6=9B=BF=E4=B8=8A=E4=B8=80=E6=8B=8D=E3=80=81=E4=B8=8D=E5=8F=A0?= =?UTF-8?q?=E6=88=90=E5=A5=BD=E5=87=A0=E6=9D=A1=E3=80=82=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E7=9A=84=E4=BC=AA=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=E7=85=A7=E5=90=8C=E4=B8=80=E4=B8=AA=E8=A7=84=E7=9F=A9=EF=BC=8C?= =?UTF-8?q?=E3=80=8C=E4=B8=80=E6=9D=A1=E5=8F=AA=E6=94=B6=E4=B8=80=E4=BB=BD?= =?UTF-8?q?=E3=80=8D=E6=AF=94=E5=BF=83=E8=B7=B3=E4=B9=8B=E5=A4=96=E7=9A=84?= =?UTF-8?q?=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/chat/job.py | 15 +++++++++++---- backend/app/chat/log.py | 9 +++++---- backend/tests/test_chat_turns.py | 14 +++++++++----- frontend/src/store/chat.ts | 4 +++- scripts/check-chat.mjs | 22 ++++++++++++++++------ 5 files changed, 44 insertions(+), 20 deletions(-) diff --git a/backend/app/chat/job.py b/backend/app/chat/job.py index b0c24e06..f93b59ab 100644 --- a/backend/app/chat/job.py +++ b/backend/app/chat/job.py @@ -16,7 +16,8 @@ **事件日志**(chat/log.py;每条 {seq, type, data, ts}): turn.attempt {mode, attempts, scope, max_steps?} 这一次尝试开始了(发起、重试本轮、重新运行、跑一下、继续运行、接着收尾) - op {...} 助手的一条操作原样(含查看、final、usage;心跳不落库,只在实时流上发,见 chat/log.beat) + op {...} 助手的一条操作原样(含查看、final、usage;心跳只落换了阶段的那一拍,同一阶段接着的只在实时流上发, + 见 chat/log.beat) stage {stage} 到了哪一步:build 建图、run 运行、waiting 停在审批上等人、review 复核 run {run_id, status, run_class} 发起了一次运行(页面据此接上运行自己的实时流看过程) review {result} 复核结论原样(含 usage、retry_instruction) @@ -141,6 +142,8 @@ class ChatDriver(TurnDriver): self.execution = execution self._thinking = "" self._thinking_at = time.monotonic() + #: 最近一次落库的心跳的阶段:同一阶段接着的心跳不落库(on_op) + self._beat_phase: Any = None #: 这一轮的查看记录(存进 meta.looks) self.looks: list[dict[str, Any]] = [] #: 等不及的那次复核(还在跑):收尾之后由它把用量记进这一轮 @@ -161,9 +164,12 @@ class ChatDriver(TurnDriver): return await self._flush_thinking() if kind == "heartbeat": - # 纯保活:只在实时流上发、不落库(chat/log.beat;以前每 3 秒落一条) - turn_log.beat(self.execution.turn_id, op) - return + if op.get("phase") == self._beat_phase: + # 同一阶段接着的心跳是纯保活:只在实时流上发、不落库(chat/log.beat;以前每 3 秒落一条) + turn_log.beat(self.execution.turn_id, op) + return + # 换了阶段的那一拍(规划 → 查看 → 搭建……)说的是到了哪一步,照落:刷新、重开时过程里那一行阶段要回放得出来 + self._beat_phase = op.get("phase") await self.execution.emit("op", op) if kind == "look" and (look := record.look_event(op)) is not None: self.looks.append(look) @@ -179,6 +185,7 @@ class ChatDriver(TurnDriver): self._thinking_at = time.monotonic() async def _generate(self, instruction: str, base_graph: dict[str, Any] | None) -> tuple[list[dict[str, Any]], str | None]: + self._beat_phase = None # 每一次生成从头算:第一拍心跳照落(页面按它画「规划」那一行) try: return await super()._generate(instruction, base_graph) finally: diff --git a/backend/app/chat/log.py b/backend/app/chat/log.py index c41fdd59..7a104110 100644 --- a/backend/app/chat/log.py +++ b/backend/app/chat/log.py @@ -12,10 +12,11 @@ {type: stream.end, status}(任务的状态)再关;隔一拍没有新事件发一行注释心跳「: ping」(页面据此知道连接还活着)。 事件种类见 app/chat/job.py 的模块说明。 -**助手的心跳不落库。** 模型几秒没出声时助手发一拍 {op: heartbeat, phase, elapsed_ms}(页面据此写阶段、已等多久):它是纯保活, -以前每 3 秒落一条,一轮几百条。现在只记在本进程里(beat:每一轮最近的一拍),流上发一条不带 seq 的 {type: op, data};重连时流 -先补最近的那一拍。之后落了任何一条事件,那一拍就作废(心跳只说「上一条事件之后还在等」,重连时补旧的会让页面的进度退回去)。 -其余事件照落:第 2 波跨进程的实时流从库里按 seq 追,别的进程的心跳那时由流按任务状态自己合成。 +**助手的心跳只落换了阶段的那一拍。** 模型几秒没出声时助手发一拍 {op: heartbeat, phase, elapsed_ms}(页面据此写阶段、已等多久): +以前每 3 秒落一条,一轮几百条。现在换了阶段的那一拍(规划 → 查看 → 搭建……,说的是到了哪一步)照落,刷新、重开时过程里那一行 +阶段回放得出来;同一阶段接着的那些是纯保活,只记在本进程里(beat:每一轮最近的一拍),流上发一条不带 seq 的 {type: op, data}; +重连时流先补最近的那一拍。之后落了任何一条事件,那一拍就作废(心跳只说「上一条事件之后还在等」,重连时补旧的会让页面的进度 +退回去)。其余事件照落:第 2 波跨进程的实时流从库里按 seq 追,别的进程的保活心跳那时由流按任务状态自己合成。 **清理。** 已结束(成功、失败、取消、中断)超过 RETAIN_DAYS 天的轮次整轮删事件日志(purge,挂在调度循环的那一拍里,一小时最多一次): 页面重开已结束的一轮读的是 conversation_turns 那一行(答案、图、复核、用量、几次尝试都在那里),只有还在进行中的轮次才回放 diff --git a/backend/tests/test_chat_turns.py b/backend/tests/test_chat_turns.py index 46d7066e..f5f851b3 100644 --- a/backend/tests/test_chat_turns.py +++ b/backend/tests/test_chat_turns.py @@ -947,8 +947,9 @@ async def test_a_stop_that_lands_while_launching_still_cancels_the_run(client, m async def test_heartbeats_go_out_on_the_stream_but_are_not_stored(client, monkeypatch, source): - """心跳是纯保活(模型几秒没出声时助手发的一拍 heartbeat 操作):只在实时流上发、不落库,重连时流自己补最近的一拍; - 落库的事件(思考、操作、阶段……)照旧。以前每 3 秒落一条,一轮下来几百条心跳。""" + """心跳(模型几秒没出声时助手发的一拍 heartbeat 操作):换了阶段的那一拍照落(刷新后过程里那一行阶段回放得出来),同一阶段 + 接着的是纯保活,只在实时流上发、不落库,重连时流自己补最近的一拍;别的事件(思考、操作、阶段……)照旧。以前每 3 秒落一条, + 一轮下来几百条心跳。""" from app.api import copilot gate = asyncio.Event() @@ -969,10 +970,10 @@ async def test_heartbeats_go_out_on_the_stream_but_are_not_stored(client, monkey event = json.loads(frame[6:]) if event.get("type") == "op" and event["data"].get("op") == "heartbeat": beats.append(event) - if len(beats) >= 2: + if any("seq" not in b for b in beats): break await stream.aclose() - assert all("seq" not in b for b in beats), "心跳不占 seq:重连按 seq 补齐时不会多出来、也不会漏" + assert [b for b in beats if "seq" not in b], "同一阶段接着的心跳不占 seq:重连按 seq 补齐时不会多出来、也不会漏" # 重连(从最后一条落库的事件接着读):流先补一拍最近的心跳,页面接着知道还在等模型 stored = await _events(turn_id) again = turn_log.stream(turn_id, stored[-1]["seq"], active=active, heartbeat=0.2) @@ -983,7 +984,10 @@ async def test_heartbeats_go_out_on_the_stream_but_are_not_stored(client, monkey gate.set() await _done(turn_id) events = await _events(turn_id) - assert not [e for e in events if e["type"] == "op" and e["data"].get("op") == "heartbeat"], "心跳一条都不落库" + stored = [e for e in events if e["type"] == "op" and e["data"].get("op") == "heartbeat"] + # 只落换了阶段的那一拍(规划、查看……各一拍),同一阶段接着的几十拍都不落 + phases = [e["data"]["phase"] for e in stored] + assert phases and phases[0] == "planning" and all(a != b for a, b in zip(phases, phases[1:])), phases assert [e["seq"] for e in events] == list(range(1, len(events) + 1)) assert turn_log.latest_beat(turn_id) is None, "这一轮结束了,进程里记的那一拍跟着清掉" diff --git a/frontend/src/store/chat.ts b/frontend/src/store/chat.ts index 3f6e4a90..5c40628f 100644 --- a/frontend/src/store/chat.ts +++ b/frontend/src/store/chat.ts @@ -1329,7 +1329,9 @@ function opPatch(t: ChatTurn, op: CopilotOp): Partial { const last = t.ops[t.ops.length - 1] const ops = op.op === 'thinking' && last?.op === 'thinking' ? [...t.ops.slice(0, -1), { ...last, delta: String(last.delta ?? '') + String(op.delta ?? '') }] - : [...t.ops, op] + // 心跳只说「还在等」:接着的一拍顶替上一拍(重连时服务端会补最近的一拍,chat/log.beat),不叠成好几条 + : op.op === 'heartbeat' && last?.op === 'heartbeat' ? [...t.ops.slice(0, -1), op] + : [...t.ops, op] switch (op.op) { case 'thinking': return { ops, phase: 'planning', thinking: (t.thinking + (op.delta ?? '')).slice(-2000) } diff --git a/scripts/check-chat.mjs b/scripts/check-chat.mjs index f1d5ab23..ca9590ae 100644 --- a/scripts/check-chat.mjs +++ b/scripts/check-chat.mjs @@ -1271,12 +1271,17 @@ async function driveTurn(T, epoch) { { cause: { kind: 'refused', message, code: got.json.code ?? null }, runId: null, usage: T.usage }) } const ops = [] + T.beatPhase = undefined // 每一次生成从头算:第一拍心跳照落 for (const op of got.ops) { if (op.gate) { await until(() => ctl[op.gate] || gone(T, epoch), 15000); continue } if (gone(T, epoch)) return - // 心跳不落库、不带 seq(服务端 chat/log.beat):只记最近的一拍,流上发一条不带 seq 的;之后落了新事件就作废 - if (op.op === 'heartbeat') T.beat = { op, at: T.events.length } - else emit(T, 'op', op) + // 心跳只落换了阶段的那一拍(服务端 chat/job.ChatDriver.on_op);同一阶段接着的不落库、不带 seq(chat/log.beat):只记 + // 最近的一拍,流上发一条不带 seq 的;之后落了新事件就作废 + if (op.op === 'heartbeat' && op.phase === T.beatPhase) T.beat = { op, at: T.events.length } + else { + if (op.op === 'heartbeat') T.beatPhase = op.phase + emit(T, 'op', op) + } if (op.op !== 'thinking' && op.op !== 'heartbeat') ops.push(op) if (op.op === 'look') T.looks.push(op) if (op.op === 'usage') T.usage.copilot = addTotal(T.usage.copilot, op) @@ -2636,9 +2641,14 @@ for (const theme of THEMES) { check('从不往回要:重连的 seq 只增不减', afters.every((a, i) => i === 0 || a >= afters[i - 1]), afters.join(',')) check('补齐:服务端这一轮记下的事件,页面一条不少地收到了', !!T && t.turnSeq === T.events.length, `${t.turnSeq} / ${T?.events.length}`) - const ops = T?.events.filter((e) => e.type === 'op').length ?? -1 - check('不重复:助手的操作一条只收一份', t.ops.length === ops && t.ops.filter((o) => o.op === 'add_node').length === 3, - `${t.ops.length} / ${ops}`) + // 心跳只落换了阶段的那一拍,同一阶段接着的不带 seq(服务端 chat/log.beat):重连时服务端会补最近的一拍,页面接着的那一拍 + // 顶替上一拍,不另起一条。按 seq 补齐的「一条只收一份」比的是心跳之外的操作 + const ops = T?.events.filter((e) => e.type === 'op' && e.data?.op !== 'heartbeat').length ?? -1 + const mine = t.ops.filter((o) => o.op !== 'heartbeat').length + check('不重复:助手的操作一条只收一份', mine === ops && t.ops.filter((o) => o.op === 'add_node').length === 3, + `${mine} / ${ops}`) + check('……补过的心跳不叠成好几条', t.ops.every((o, i) => o.op !== 'heartbeat' || t.ops[i + 1]?.op !== 'heartbeat'), + t.ops.map((o) => o.op).join(',')) check('画布边收边长到收尾:图有 3 个节点,运行接上了', t.graph?.nodes?.length === 3 && !!t.run?.id) check('页面不写这一轮的结局(不 PATCH)', log.patches.slice(patchesBefore).length === 0) await page.getByRole('button', { name: '停止这一轮' }).click().catch(() => {})