From 3f75cb1d2ea75585c1ed448db549485c1b6f0bf6 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Sat, 17 Oct 2026 08:31:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E8=A2=AB=E9=A9=B3=E5=9B=9E?= =?UTF-8?q?=E7=9A=84=E8=BF=90=E8=A1=8C=EF=BC=9A=E7=85=A7=20end=5Freason=20?= =?UTF-8?q?=E8=AF=B4=E3=80=8C=E8=A2=AB=E9=A9=B3=E5=9B=9E=E3=80=8D=EF=BC=8C?= =?UTF-8?q?=E5=92=8C=E7=BB=A7=E7=BB=AD=E8=BF=90=E8=A1=8C=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E9=94=99=E8=AF=AF=E7=A0=81=20run=5Frejected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 验收:对被驳回的运行调 POST /api/runs/{id}/resume,回的是「这次运行已取消,没有待处理的审批,无法恢复。已取消的 运行请重新发起」,/continue 却是对的(run_rejected)。resume 只看 status,cancelled 一律说「已取消」;存量的 failed + 驳回还会被说成「失败的运行可点继续运行」,而继续运行其实会被拒。 改法:runner 加 _resume_refused:cancelled + end_reason=rejected、以及加 end_reason 之前记成 failed 的驳回 (_rejected_before,同 continue_failed 的判断)抛 ContinueRefused(REJECTED_RESUME, code=RUN_REJECTED),别的照 _cannot_resume。resume 开头的状态检查、等审批凑齐时发现运行已经不在 interrupted 两处都用它。/resume 接口像 /continue 一样把带机器码的拒绝回成 CodedHTTPException(409, …, code)。ContinueRefused 是 ValueError 的子类, 别的调用方(待办服务)照旧按 ValueError 接。 测试:test_run_rejected 新增一条:驳回的运行 /resume 回 409 + run_rejected、说「被驳回」;手动停止的照旧说 已取消、不带这个码;存量 failed + 驳回也回 run_rejected,不再叫人点「继续运行」。去掉修复回跑挂。 --- backend/app/api/runs.py | 5 +++++ backend/app/engine/runner.py | 18 +++++++++++++++--- backend/tests/test_run_rejected.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/backend/app/api/runs.py b/backend/app/api/runs.py index f06d8b4a..606f82ad 100644 --- a/backend/app/api/runs.py +++ b/backend/app/api/runs.py @@ -418,6 +418,8 @@ async def resume_run( approval = await session.get(Approval, target) if approval is not None and approval.run_id == run_id and approval.status == "pending": return await _respond_to(target, payload.response, x_actor) + from app.engine.runner import ContinueRefused + try: # approval_id 以前定义了却从没传下去——一次运行里有多条待审批时, # 引擎无从知道这次回复的是哪一条 @@ -428,6 +430,9 @@ async def resume_run( ) except KeyError as e: raise _missing(e) from e + except ContinueRefused as e: + # 被驳回的(run_rejected):和 /continue 同一个机器码 + raise CodedHTTPException(409, str(e), e.code) from e except ValueError as e: raise HTTPException(409, str(e)) from e diff --git a/backend/app/engine/runner.py b/backend/app/engine/runner.py index ac31b861..c3acfd8c 100644 --- a/backend/app/engine/runner.py +++ b/backend/app/engine/runner.py @@ -52,6 +52,8 @@ END_REJECTED = "rejected" END_STOPPED = "stopped" #: 被驳回的运行不能继续运行:从断点重跑会读到同一个驳回答复、再停在同一处 REJECTED_CONTINUE = "被驳回的运行不能继续运行:改好内容后重新发起" +#: 被驳回的运行也没有可恢复的审批(恢复、继续运行同一个错误码 RUN_REJECTED) +REJECTED_RESUME = "这次运行已被驳回,没有待处理的审批,无法恢复:改好内容后重新发起" #: 服务重启时还在排队的运行改成失败时写的话(run.error 和补发的 run.failed 是同一句) QUEUED_AT_RESTART = "服务重启时这次运行仍在排队,没有可恢复的进度,请重新发起运行" @@ -355,7 +357,7 @@ class RunManager: if not run: raise KeyError("运行记录不存在") if run.status not in ("interrupted",): - raise ValueError(_cannot_resume(run.status)) + raise await _resume_refused(session, run) spec = GraphSpec.model_validate(run.graph) workflow_id = run.workflow_id carried = _carried(run) @@ -458,8 +460,8 @@ class RunManager: await session.commit() run = await session.get(Run, run_id) if run.status != "interrupted": - # 审批没了是因为运行在这期间被放弃了:不能回一个「已记下」 - raise ValueError(_cannot_resume(run.status)) + # 审批没了是因为运行在这期间被放弃了(或被别人驳回了):不能回一个「已记下」 + raise await _resume_refused(session, run) return run # 齐了:正式落 resolved,然后驱动引擎。谁批的跟着交给节点, @@ -1186,6 +1188,16 @@ _STATUS_TEXT = { } +async def _resume_refused(session: Any, run: Run) -> ValueError: + """恢复被拒时抛什么:被驳回的(cancelled + end_reason=rejected,以及加 end_reason 之前记成 failed 的驳回)说「被驳回」、 + 带 RUN_REJECTED,和继续运行同一个错误码(以前照状态说「已取消的运行请重新发起」、对存量的还叫人点「继续运行」, + 可继续运行也会被拒);别的照状态说(_cannot_resume)。""" + if (run.status == "cancelled" and run.end_reason == END_REJECTED) or ( + run.status == "failed" and await _rejected_before(session, run)): + return ContinueRefused(REJECTED_RESUME, code=RUN_REJECTED) + return ValueError(_cannot_resume(run.status)) + + def _cannot_resume(status: str) -> str: hint = { "running": "运行仍在进行,无需恢复。", diff --git a/backend/tests/test_run_rejected.py b/backend/tests/test_run_rejected.py index 1da7e538..18f5cf35 100644 --- a/backend/tests/test_run_rejected.py +++ b/backend/tests/test_run_rejected.py @@ -186,6 +186,35 @@ async def test_the_api_carries_end_reason_and_refuses_to_continue_a_rejected_run assert stop.status_code == 409 and "被驳回" in stop.json()["detail"] +async def test_resume_on_a_rejected_run_says_rejected_with_the_same_code(client): + """对被驳回的运行调 /resume:照 end_reason 说「被驳回」,和 /continue 同一个错误码 run_rejected(下一轮验收:以前说 + 「这次运行已取消…已取消的运行请重新发起」)。手动停止的照旧说已取消;存量的 failed + 驳回也说被驳回,不再叫人点 + 「继续运行」。""" + run_id = await _rejected_run("approve") + await _wait(run_id, ("cancelled",)) + r = await client.post(f"/api/runs/{run_id}/resume", json={"response": {"approved": True}}) + assert r.status_code == 409 and r.json()["code"] == "run_rejected", r.text + assert "被驳回" in r.json()["detail"] and "已取消" not in r.json()["detail"] + async with SessionLocal() as session: + stopped = Run(workflow_name="停掉的", status="cancelled", end_reason="stopped", graph=chain(START, OUT), input={}) + old = Run(workflow_name="老的驳回", status="failed", graph=chain(START, GATES["approve"][0], OUT), input={}, + error="人工驳回:不对", error_node_id="gate") + session.add_all([stopped, old]) + await session.flush() + session.add_all([ + RunEvent(run_id=old.id, seq=1, type="node.failed", node_id="gate", ts=1.0, data={"error": "人工驳回:不对"}), + Task(run_id=old.id, node_id="gate", kind="approve", title="可以吗", status="resolved", + outcome={"decision": "reject"}), + ]) + await session.commit() + stopped_id, old_id = stopped.id, old.id + other = await client.post(f"/api/runs/{stopped_id}/resume", json={"response": {"approved": True}}) + assert other.status_code == 409 and other.json().get("code") != "run_rejected" and "已取消" in other.json()["detail"] + legacy = await client.post(f"/api/runs/{old_id}/resume", json={"response": {"approved": True}}) + assert legacy.status_code == 409 and legacy.json()["code"] == "run_rejected", legacy.text + assert "继续运行" not in legacy.json()["detail"].replace("不能继续运行", "") + + async def test_a_legacy_failed_rejection_cannot_be_continued_either(client): """加 end_reason 之前被驳回的运行记的是 failed(封存不改写):失败的那一步上最近一条处理完的待办是驳回,也不许接着跑。 别的失败照旧能接着跑(这里断点不在,说的是没有断点,不是被驳回)。"""