恢复被驳回的运行:照 end_reason 说「被驳回」,和继续运行同一个错误码 run_rejected

验收:对被驳回的运行调 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,不再叫人点「继续运行」。去掉修复回跑挂。
This commit is contained in:
ThearchyHelios
2026-10-17 08:31:17 +08:00
parent c64c7a1062
commit 3f75cb1d2e
3 changed files with 49 additions and 3 deletions
+5
View File
@@ -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
+15 -3
View File
@@ -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": "运行仍在进行,无需恢复。",
+29
View File
@@ -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(封存不改写):失败的那一步上最近一条处理完的待办是驳回,也不许接着跑。
别的失败照旧能接着跑(这里断点不在,说的是没有断点,不是被驳回)。"""