fix(soak): 崩溃改成子进程内部的确定性自杀,外部 SIGKILL 抢不到那个窗口

第一次实跑的结果:时机 A(一步完整落地之后崩)三次都没命中,每次都是「发信号与子进程停笔
之间又写进了记录」。原因是库写完步记录之后紧接着就写下一条意图,中间只有内存计算,窗口
窄到外部信号挤不进去。这个观察本身留在模块 docstring 里——它说明自然崩溃几乎总是落在
「有意图没结果」那一态上。

改成在子进程里包一层存储,在写入落盘返回之后按条件调 os._exit(137)。os._exit 不跑
finally、不跑 atexit、不 flush,对磁盘的效果与 SIGKILL 等价,而 JsonlRunStore 本来就
写完即 fsync,没有未刷缓冲要指望退出时替它写。外部 SIGKILL 那条路降级成兜底,没有删。

自杀条件都要求工作区审计账已经非空,即那个声明绝不重放的写入真的执行过。上一次实跑里
最硬的那条判据(绝不重放的动作没被执行两次)报的是无法判定,就是因为崩得太早、审计账
是空的,去重比对真空成立——判定器诚实地报了无法判定而不是绿,现在给它补上实料。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 09:20:01 -04:00
parent 43971573b7
commit fe33faa3ec
2 changed files with 532 additions and 70 deletions
+249 -59
View File
@@ -16,6 +16,13 @@
只会随机变红。这里的判据全是结构不变量:字节前缀、步号连续性、意图有没有归宿、审计账去重
前后的条数、停止原因的取值、步数与上限的关系、环境侧的执行计数。
**崩溃是子进程自己在指定的写入点上 `os._exit`,不是父进程从外面抢窗口发信号。** 第一版靠
父进程轮询日志尾部再 `SIGKILL`,实测三次全都没能命中「一步完整落地之后」那个时机——库写完
步记录紧接着就写下一步的模型调用意图,中间只有内存里的装配计算,窗口窄到信号挤不进去。
**那次实测本身是一条要记住的事实**:自然发生的崩溃几乎总是落在「有意图没结果」那一态上,
而不是落在两步之间的干净边界上。但要验那个边界就不能靠碰运气。做法见 `SelfKillingStore`
外部 SIGKILL 那条路留着兜底,没有删。
**产物照 `tools/soak/scoreboard.py` 的 sidecar 约定写出**`.result.json` / `.events.jsonl` /
`.meta.json`),`meta.fault` 填故障名,这样记分板能把它们和正常批次一起判。崩溃那两类天然
缺 sidecar——被 SIGKILL 的子进程来不及写,记分板会报「无法判定」,那是对的,不为了让它变绿
@@ -33,20 +40,31 @@ from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
import time
from collections.abc import Awaitable, Mapping, Sequence
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass, replace
from enum import StrEnum
from pathlib import Path
from polyloop import session
from polyloop.adapters import GatewayModelClient
from polyloop.ports import Event, InvalidDecision, ParsedReply
from polyloop.ports import Event, InvalidDecision, ParsedReply, RunLog, RunStore
from polyloop.serialization import encode
from polyloop.session import AgentDefinition, ParameterDriftError, RunRequest
from polyloop.stores import RECORD_KEY, JsonlRunStore
from polyloop.types import Budget, ModelReply, RunResult
from polyloop.types import (
Budget,
Intent,
ModelCallResult,
ModelReply,
ReplayPolicy,
RunFinished,
RunResult,
RunStarted,
StepCompleted,
)
from tools.soak.appworld import AppWorldPool
from tools.soak.scenarios import appworld as appworld_scenario
from tools.soak.scenarios import govdoc as govdoc_scenario
@@ -596,12 +614,17 @@ def check_lease_returned(*, borrowed: bool, timeout_s: float, pool_size: int) ->
# ---------------------------------------------------------------------------
# 五、子进程编排:轮询日志尾部,按时机 SIGKILL
# 五、子进程编排:子进程按时机自杀,外部 SIGKILL 兜底
# ---------------------------------------------------------------------------
#: 子进程按时机自杀时用的退出码。父进程靠它区分「按预期崩在时机上」与「因为别的原因退出」。
#:
#: 取 137 是照 128+9 那个惯例(外部 SIGKILL 的等价形态),让两条路径在日志里读起来是同一件事。
CRASH_EXIT_CODE = 137
class KillTiming(StrEnum):
"""在哪个时机子进程掉。两种时机的判据不同,不许混成一个用例。"""
"""在哪个时机子进程掉。两种时机的判据不同,不许混成一个用例。"""
#: 时机 A:最后一条是 `step_completed`,一步完整落地之后崩。续跑该真的接着往下跑。
AFTER_STEP = "after_step"
@@ -611,7 +634,9 @@ class KillTiming(StrEnum):
def should_kill(read: LogRead, *, timing: KillTiming, after_steps: int) -> bool:
"""现在这份日志尾部是不是要等的那个时机。纯函数,轮询循环每次拿它问一句
"""现在这份日志尾部是不是要等的那个时机。纯函数。
子进程自杀之后父进程拿它复核一遍尾部形态;外部 SIGKILL 那条兜底路径每次轮询也问它一句。
时机 B 额外要求那条意图的重放策略是 `never`。**这比「最后一条是意图」更严**,而且必须
更严:GovDoc 的 `read_document` 与 `grep_document` 声明的是 `safe`,悬在那种意图上续跑
@@ -628,20 +653,127 @@ def should_kill(read: LogRead, *, timing: KillTiming, after_steps: int) -> bool:
return last.get(RECORD_KEY) == "intent" and last.get("replay_policy") == "never"
class SelfKillingStore:
"""包一层 `RunStore`:某一次写入落盘返回之后,按时机让子进程当场死掉。
**为什么不再靠外部 SIGKILL 抢窗口。** 实测下来时机 A 一次都没命中过,三次全都报「发信号
与子进程停笔之间又写进了记录」:库写完 `step_completed` 紧接着就写下一步的模型调用意图,
中间只有内存里的装配计算,那个窗口窄到外面的信号挤不进去。**这个观察本身值得记住**——
它说明自然发生的崩溃几乎总是落在「有意图没结果」那一态上,而不是落在两步之间的干净边界
上。但要验时机 A 就不能靠碰运气,得让子进程自己在那一点上死。
**`os._exit` 与 SIGKILL 对磁盘的效果等价。** 它不跑 `finally`、不跑 `atexit`、不 flush
任何缓冲,直接进 `_exit(2)` 系统调用。`JsonlRunStore` 每次写完自己 `fsync`(意图与运行
开始、运行结束三处)或者靠同一文件的追加序兜(`0005` 决策五那条前缀持久性),本来就没有
未刷缓冲要指望进程退出时替它写下去,所以「有没有机会清理」在这里不影响磁盘上留下什么。
**`parameters()` 原样转发内层的**,不加自己的键:它进参数快照,而父进程续跑时用的是一个
没包过的 `JsonlRunStore`。加一个键,续跑就报参数漂移,而那是一次假故障。
**自杀条件带上「审计账已经非空」**:要验的最硬那条判据是「声明绝不重放的动作没有被执行
两次」,它数的是工作区审计账。崩在模型还没调过 `write_note` 的时候,账是空的,那条判据
真空成立——报出来是「通过」,实际什么都没验。
"""
__slots__ = ("_exit_now", "_inner", "_timing", "_workspace")
def __init__(
self,
*,
inner: RunStore,
timing: KillTiming,
workspace: Path,
exit_now: Callable[[int], object] = os._exit,
) -> None:
"""Args:
inner: 真正写盘的那个存储。
timing: 在哪一次写入之后死。
workspace: 数审计账用的工作区目录。
exit_now: 怎么死。**做成参数是为了能测**——默认的 `os._exit` 在测试里会把 pytest
自己一起带走,测试注入一个抛哨兵异常的替身。
"""
self._inner = inner
self._timing = timing
self._workspace = Path(workspace)
self._exit_now = exit_now
def parameters(self) -> Mapping[str, str]:
return self._inner.parameters()
def _audit_is_not_empty(self) -> bool:
return bool(read_audit_lines(self._workspace))
async def read_log(self, run_id: str) -> RunLog:
return await self._inner.read_log(run_id)
async def write_run_started(self, record: RunStarted) -> None:
await self._inner.write_run_started(record)
async def write_intent(self, record: Intent) -> None:
await self._inner.write_intent(record)
if (
self._timing is KillTiming.AT_INTENT
and record.replay_policy is ReplayPolicy.NEVER
and self._audit_is_not_empty()
):
self._exit_now(CRASH_EXIT_CODE)
async def write_model_call_result(self, record: ModelCallResult) -> None:
await self._inner.write_model_call_result(record)
async def write_step_completed(self, record: StepCompleted) -> None:
await self._inner.write_step_completed(record)
if self._timing is KillTiming.AFTER_STEP and self._audit_is_not_empty():
self._exit_now(CRASH_EXIT_CODE)
async def write_run_finished(self, record: RunFinished) -> None:
await self._inner.write_run_finished(record)
@dataclass(frozen=True, slots=True, kw_only=True)
class KillOutcome:
"""一次「起子进程、等时机、杀掉」的结果。"""
"""一次「起子进程、等它崩在时机上」的结果。"""
#: 时机命中了没有。没命中要重试,重试若干次仍不命中要报出来——悄悄降级成另一种时机会让
#: 报告显示「验过了」而其实验的是另一件事。
hit: bool
reason: str
#: 杀掉之后重读日志、截到最后一个换行为止的那一段。字节比对拿它当基准。
#: 崩溃之后重读日志、截到最后一个换行为止的那一段。字节比对拿它当基准。
snapshot: bytes = b""
#: 崩溃时已经完整落地的步数。
steps: int = 0
#: 这次子进程发起过几次模型调用(含被杀在半路的那次),记账用。
#: 这次子进程发起过几次模型调用(含在半路的那次),记账用。
model_calls: int = 0
#: 子进程的退出码。按预期自杀是 `CRASH_EXIT_CODE`,被外部 SIGKILL 兜底掉是 -9。
exit_code: int | None = None
def _judge_crash(
*, log_path: Path, timing: KillTiming, after_steps: int, exit_code: int | None, how: str
) -> KillOutcome:
"""崩溃之后重读一次日志,判尾部形态是不是要的那个时机。
**不管子进程是自杀的还是被外部信号杀的,都要重判一次。** 自杀那条路上判的是「自杀条件与
时机判据说的是不是同一件事」;外部信号那条路上判的是「读日志与发信号之间它有没有又写进
一条」——不重判的话,一次「本想在时机 A 杀、实际杀在时机 B」会被当成时机 A 判下去。
"""
crashed = log_path.read_bytes() if log_path.is_file() else b""
after = parse_terminated(crashed)
if not should_kill(after, timing=timing, after_steps=after_steps):
return KillOutcome(
hit=False,
reason=f"{how}之后重读日志,尾部不是时机 {timing.value} 要的形态",
model_calls=count_model_calls(after),
exit_code=exit_code,
)
return KillOutcome(
hit=True,
reason=f"{how},命中时机 {timing.value}",
snapshot=terminated_prefix(crashed),
steps=len(tagged(after, "step_completed")),
model_calls=count_model_calls(after),
exit_code=exit_code,
)
async def spawn_and_kill(
@@ -650,61 +782,74 @@ async def spawn_and_kill(
log_path: Path,
timing: KillTiming,
after_steps: int,
child_log_path: Path | None = None,
poll_interval_s: float = 0.002,
timeout_s: float = 600.0,
) -> KillOutcome:
"""起一个子进程,轮询它的日志,命中时机就 `SIGKILL`
"""起一个子进程,等它崩在时机上
**用 SIGKILL 不用 SIGTERM**:要的是没有任何清理机会的死法。SIGTERM 会走 Python 的信号
处理,`finally` 有机会跑完,那验的是优雅退出而不是崩溃
**主路径是子进程自己在时机上 `os._exit`**(见 `SelfKillingStore`),父进程只负责认领:
退出码等于 `CRASH_EXIT_CODE` 且日志尾部形态对得上,就算命中
杀掉之后**重读一次日志再判时机是不是还成立**:读日志与发信号之间子进程还在写,读到的
尾部可能已经不是杀掉那一刻的尾部了。不重判的话,一次「本想在时机 A 杀、实际杀在时机 B」
会被当成时机 A 判下去。
**外部 SIGKILL 那条路留着兜底**,没有删掉:子进程那侧的自杀条件万一因为别的原因没触发
(包装漏了一处、审计账一直是空的),轮询仍然会在尾部形态对上的那一刻把它杀掉。**用
SIGKILL 不用 SIGTERM**,要的是没有任何清理机会的死法——SIGTERM 会走 Python 的信号处理,
`finally` 有机会跑完,那验的是优雅退出而不是崩溃。
子进程的输出**写进文件而不是管道**:管道缓冲区满了子进程会阻塞在写上,而表现是「它卡住
不动了」,从外面区分不出是卡在模型调用上还是卡在一行日志上。
"""
process = await asyncio.create_subprocess_exec(
*argv,
cwd=str(REPO_ROOT),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
if child_log_path is None:
sink: object = asyncio.subprocess.DEVNULL
handle = None
else:
child_log_path.parent.mkdir(parents=True, exist_ok=True)
handle = child_log_path.open("ab")
sink = handle
try:
process = await asyncio.create_subprocess_exec(
*argv, cwd=str(REPO_ROOT), stdout=sink, stderr=asyncio.subprocess.STDOUT
)
finally:
if handle is not None:
handle.close()
deadline = time.monotonic() + timeout_s
try:
while True:
if process.returncode is not None:
stdout, stderr = await process.communicate()
tail = stderr.decode("utf-8", errors="replace").strip().splitlines()[-3:]
del stdout
code = process.returncode
if code == CRASH_EXIT_CODE:
return _judge_crash(
log_path=log_path,
timing=timing,
after_steps=after_steps,
exit_code=code,
how=f"子进程按时机自杀(退出码 {code}",
)
return KillOutcome(
hit=False,
reason=(
f"子进程在命中时机之前就退出了(退出码 {process.returncode}"
+ (f"stderr 末尾:{' / '.join(tail)}" if tail else "")
f"子进程退出码 {code} 结束,不是按时机自杀的 {CRASH_EXIT_CODE}"
+ (f"输出见 {child_log_path.name}" if child_log_path else "")
),
model_calls=count_model_calls(read_log(log_path)),
exit_code=code,
)
if should_kill(read_log(log_path), timing=timing, after_steps=after_steps):
process.kill()
await process.wait()
crashed = log_path.read_bytes() if log_path.is_file() else b""
after = parse_terminated(crashed)
if not should_kill(after, timing=timing, after_steps=after_steps):
return KillOutcome(
hit=False,
reason="发信号与子进程停笔之间又写进了记录,杀掉之后时机已经不成立",
model_calls=count_model_calls(after),
)
return KillOutcome(
hit=True,
reason=f"命中时机 {timing.value}",
snapshot=terminated_prefix(crashed),
steps=len(tagged(after, "step_completed")),
model_calls=count_model_calls(after),
return _judge_crash(
log_path=log_path,
timing=timing,
after_steps=after_steps,
exit_code=process.returncode,
how="父进程兜底发了 SIGKILL",
)
if time.monotonic() > deadline:
return KillOutcome(
hit=False,
reason=f"等了 {timeout_s} 秒仍没命中时机 {timing.value}",
reason=f"等了 {timeout_s} 秒仍没崩在时机 {timing.value}",
model_calls=count_model_calls(read_log(log_path)),
)
await asyncio.sleep(poll_interval_s)
@@ -910,20 +1055,31 @@ CRASH_PHASE = "execute"
#: **由父进程用普通文件写入放进去,不走 `write_note`**:走工具的话审计账里会先躺一条记录,
#: 而那条记录不是这次运行产生的,会把去重比对的基数弄脏。
#:
#: 正文全是自造的,一个字都不取自真实文书。
#: **写得极其直白,只留一处候选、只留一条待办**:两种崩溃时机的触发条件都要求审计账已经非空,
#: 也就是模型必须先真的调过一次 `write_note`。计划写得散一点,模型会先检索几轮再动笔,六步的
#: 预算跑完都还没写过笔记,那一类就只能报「无法判定」。这里不改 `govdoc.py` 的阶段提示词
#: (那是场景层的东西),只改这份由本文件自己造的计划正文。
#:
#: 行号是随手挑的一段正文,正文内容不在这里出现,也没有任何一个字取自真实文书。
SEEDED_PLAN_NAME = "plan.md"
SEEDED_PLAN_TEXT = """# 审核计划(故障注入脚本预置)
候选证据两条,逐条核实:
候选证据只有一处,已经定位好了:**tender.md 第 880 到第 910 行**。
1. tender.md 第 1 到第 60 行:项目基本信息与采购人信息,用来确认主体与项目编号。
2. tender.md 第 200 到第 260 行:供应商资格条件,审核点多半落在这一段。
只做两件事,做完就停:
核实完成后把逐条摘录写进 evidence.md,写完就停下
1. 用 read_document 读 tender.md 的第 880 到第 910 行
2. 立刻用 write_note 把上一步读到的原文逐字摘录写进 evidence.md,注明文档名与行号区间。
不要再检索别的关键词,不要再读别的段落——其余部分与本审核点无关。evidence.md 写完就停下。
"""
#: 崩溃续跑这一路的预算。**比 GovDoc 场景自己那份(50 步)小得多**:这里要验的是崩溃与续跑
#: 的接缝,接缝在头几步就压得到,而每多一步都是一次真实的模型调用。
#:
#: 六步是留给「读一段 + 写一次笔记」的余量:崩溃的触发条件要求模型先真的调过一次 `write_note`
#: (见 `SEEDED_PLAN_TEXT`),照计划走是第 2 步就写,留到六步是给它两三次走弯路的机会。跑到
#: 预算用完仍然一次笔记都没写过,那一类报「无法判定」并说明原因,不假装验过。
CRASH_BUDGET = Budget(
max_steps=6,
max_actions=6,
@@ -934,9 +1090,12 @@ CRASH_BUDGET = Budget(
#: 模型绑定必须在父子两侧逐字相同——它整个进参数快照,差一个键续跑就报参数漂移。
CRASH_MODEL_BINDING: Mapping[str, str] = {"scenario": "govdoc", "phase": CRASH_PHASE}
#: 时机判定之前先等几步落地。等到第 2 步是为了让模型有机会走到 `write_note` 那一步,否则
#: 审计账全程为空,最硬那条判据就只能报「无法判定」。
CRASH_AFTER_STEPS = 2
#: 时机判据里「至少已经落地几步」这一项。
#:
#: **取 1,不再拿它当「等模型写笔记」的代理**:等笔记这件事现在由自杀条件本身负责(审计账
#: 非空),而自杀点可能落在第 1 步的 `step_completed` 上。这里再要求两步的话,一次本来完全
#: 正确的崩溃会被判成没命中。留着 1 是为了保证崩溃前至少有一步完整轨迹可供字节比对与续跑。
CRASH_AFTER_STEPS = 1
APPWORLD_MODEL_BINDING: Mapping[str, str] = {"scenario": "appworld"}
@@ -1029,7 +1188,7 @@ async def run_crash_fault(
guard: CallGuard,
attempts: int,
) -> FaultReport:
"""一类崩溃续跑:起子进程 → 按时机 SIGKILL → 拷字节 → 同一个 run_id 续跑 → 逐条判。"""
"""一类崩溃续跑:起子进程 → 按时机自杀 → 拷字节 → 同一个 run_id 续跑 → 逐条判。"""
task = load_govdoc_task(govdoc_db=govdoc_db, govdoc_corpus=govdoc_corpus)
notes: list[str] = []
for attempt in range(1, attempts + 1):
@@ -1055,18 +1214,31 @@ async def run_crash_fault(
run_id,
"--workspace",
str(workspace),
"--timing",
timing.value,
"--govdoc-db",
str(govdoc_db),
"--govdoc-corpus",
str(govdoc_corpus),
]
outcome = await spawn_and_kill(
argv=argv, log_path=log_path, timing=timing, after_steps=CRASH_AFTER_STEPS
argv=argv,
log_path=log_path,
timing=timing,
after_steps=CRASH_AFTER_STEPS,
child_log_path=runs_dir / f"{run_id}.child.log",
)
guard.charge(outcome.model_calls)
if not outcome.hit:
audit = read_audit_lines(workspace)
why_empty = (
";这次跑到结束都没调过一次 write_note,审计账是空的,所以自杀条件从来没满足过"
if not audit
else ""
)
notes.append(
f"{attempt} 次没命中时机:{outcome.reason}(花了 {outcome.model_calls} 次调用)"
f"{attempt} 次没崩在时机{outcome.reason}"
f"(花了 {outcome.model_calls} 次调用){why_empty}"
)
write_sidecars(
runs_dir=runs_dir,
@@ -1084,7 +1256,10 @@ async def run_crash_fault(
)
continue
notes.append(f"{attempt} 次命中时机 {timing.value},崩溃时 {outcome.steps}")
notes.append(
f"{attempt}{outcome.reason},崩溃时 {outcome.steps} 步、"
f"审计账 {len(read_audit_lines(workspace))}"
)
return await _resume_and_judge(
fault=fault,
timing=timing,
@@ -1103,8 +1278,8 @@ async def run_crash_fault(
fault=fault,
criteria=(
undetermined(
"kill_timing_hit",
f"{attempts} 次都没能子进程在时机 {timing.value} 上,这一类什么都没验成",
"crash_timing_hit",
f"{attempts} 次都没能子进程在时机 {timing.value} 上,这一类什么都没验成",
),
),
notes=tuple(notes),
@@ -1468,9 +1643,13 @@ async def run_parse_failure_fault(
async def run_child(args: argparse.Namespace) -> int:
"""子进程:跑一次 GovDoc execute 阶段,等着被父进程杀掉
"""子进程:跑一次 GovDoc execute 阶段,跑到指定时机就把自己打死
正常跑完也是允许的——那时父进程会报「没命中时机」并重试,不会把它当成命中。
**打死自己这件事由 `SelfKillingStore` 在写入落盘之后做**,不在这里判——判据要贴着那次写
才准,隔一层就又变成抢窗口了。它调的是 `os._exit`,所以这个函数在命中时机时根本不返回,
下面的 `client.aclose()` 与 `finally` 都不会跑。**那正是要的**:崩溃就是不给清理机会。
正常跑完也是允许的(时机一次都没触发),那时父进程会报「没崩在时机上」并重试。
**事件写在 `<run_id>.child.events.jsonl`,不写记分板认的那个名字。** 事件只在「本进程里
真的走完」的步上发,而记分板按 `meta.resumed_from_step` 把续跑跳过的那一段减掉之后与
@@ -1483,7 +1662,11 @@ async def run_child(args: argparse.Namespace) -> int:
runs_dir = Path(args.runs_dir)
workspace = Path(args.workspace)
task = load_govdoc_task(govdoc_db=Path(args.govdoc_db), govdoc_corpus=Path(args.govdoc_corpus))
store = JsonlRunStore(directory=runs_dir)
store = SelfKillingStore(
inner=JsonlRunStore(directory=runs_dir),
timing=KillTiming(args.timing),
workspace=workspace,
)
sink = JsonlEventSink(runs_dir / f"{args.run_id}.child.events.jsonl")
client = GatewayClient.from_env()
try:
@@ -1533,6 +1716,11 @@ def build_parser() -> argparse.ArgumentParser:
)
parser.add_argument("--run-id", help="内部用:子进程的运行标识")
parser.add_argument("--workspace", help="内部用:子进程的工作区目录")
parser.add_argument(
"--timing",
choices=[item.value for item in KillTiming],
help="内部用:子进程在哪一次写入之后把自己打死",
)
return parser
@@ -1712,7 +1900,7 @@ def main(argv: Sequence[str] | None = None) -> int:
if args.child:
missing = [
name
for name in ("run_id", "workspace", "govdoc_db", "govdoc_corpus")
for name in ("run_id", "workspace", "timing", "govdoc_db", "govdoc_corpus")
if not getattr(args, name)
]
if missing:
@@ -1732,6 +1920,7 @@ if __name__ == "__main__":
__all__ = [
"APPWORLD_FAULTS",
"CRASH_BUDGET",
"CRASH_EXIT_CODE",
"FAULT_NAMES",
"GOVDOC_FAULTS",
"AlwaysInvalidParser",
@@ -1744,6 +1933,7 @@ __all__ = [
"KillOutcome",
"KillTiming",
"LogRead",
"SelfKillingStore",
"check_all_steps_parse_failed",
"check_audit_unchanged",
"check_cancelled_raised",