fix(soak): 按 Codex 对抗审查加固故障注入的判据
九类实跑全过之后让 Codex 专门找「判据其实验不到它声称要验的东西」,它报的每条都带具体 失败场景。这次的绿不是假的——实跑数据里这些判据都有料可判——但它们在别的输入下会假绿。 真空成立三处改成无法判定:空日志的「全都解得开」、零步的「没有 env_error」、前后都空的 「审计账没变」。上一版实跑里最后那条正是 0 vs 0 通过的。 「环境是在完整一步之后坏的」原本只有通过和无法判定两档,没有击穿分支——那条判的是注入器 自己,它对外宣称在第 N 次执行之后动手,整类故障的结论都建立在这句话上。 提示词那两条原本只读库写进日志的数,而这套东西反复强调不能拿库的自述验库的行为——它在 自己的核心主张上破了例。现在包一层模型客户端记下每次真正发出去的消息字符数,跑完逐步 对账。字符数口径与库的算法对拍过,不然会全程假击穿。 绝不重放那条补了两个角度:崩溃时账上那几条续跑之后要逐行原样还在(旧条目被改写、被抹掉 原来一路绿灯),以及续跑段里不许出现与崩溃前完全相同的条目。Codex 提的「同一文件不同 内容」在 b 档已被条数判据挡住,a 档不能加同样的规则——那一档续跑本来就该接着跑,模型 再写一次是正常行为,加了会变成随机红。 取消补了环境侧的静默判据与事件文件完整性。容器里已经发出去的执行在客户端计数上不留痕, 这个盲区如实写进已知缺口,没假装验到;崩溃形态只能落在写入调用返回之后,同样记下来。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+329
-13
@@ -39,6 +39,26 @@ GovDoc 那一路把 `max_prompt_chars` 压到刚好等于装配出来的初始
|
||||
缺 sidecar——被 SIGKILL 的子进程来不及写,记分板会报「无法判定」,那是对的,不为了让它变绿
|
||||
去补假数据。
|
||||
|
||||
## 已知缺口
|
||||
|
||||
这两条是这套注入器**造不出来**的情形。写在这里是因为「没被验到」与「验过了没问题」在报告上
|
||||
长得一样,而报告只列跑过的判据,列不出没跑的那些。
|
||||
|
||||
**一、崩溃形态只有「写入调用返回之后」这一种。** `os._exit` 是一次系统调用,它只能发生在两条
|
||||
Python 语句之间;进程内的自杀不可能停在 `write(2)` 的中途。所以「一行只写了半截就断电」
|
||||
「记录进了页缓存但 `fsync` 之前机器崩了」这两种形态在这里造不出来——真要造得靠外部手段(掐
|
||||
电、`dm-flakey` 这类会掉写的块设备、或者在文件系统层注入故障),那是另一套东西。现在靠的是
|
||||
记分板那侧的撕裂行判据:它按「有没有被换行终结」切行,末尾那段没终结的字节按契约算没发生过
|
||||
(`tools/soak/scoreboard.py` 的 `_split_terminated`)。也就是说这种日志**读得对**是验过的,
|
||||
**写出来会怎样**没验过。
|
||||
|
||||
**二、取消之后容器里那次执行的下落看不见。** `AppWorldSession.n_executions` 是客户端侧的
|
||||
计数,HTTP 响应返回之后才加一,而取消把那个协程掐断了。于是「代码已经发到容器、容器仍然把它
|
||||
跑完并改了环境状态」这种情形留不下痕迹。容器那侧没有可查的执行计数接口(只有 `/execute`、
|
||||
`/task_completed`、`/evaluate`、`/close`),补上它要改 `tools/soak/appworld.py` 那一层。
|
||||
`check_env_quiet_after_cancel` 覆盖的是另外两种坏法:库在取消之后还往环境派活,或者有一次
|
||||
执行在取消之后才落地。
|
||||
|
||||
跑法(会打真实模型、会花钱)::
|
||||
|
||||
PYTHONUNBUFFERED=1 conda run --live-stream -n PolyLoop python -m tools.soak.faults \\
|
||||
@@ -315,12 +335,21 @@ def check_log_readable(read: LogRead) -> Criterion:
|
||||
|
||||
解不出来的行让下面几条判据判的东西缺一块,所以它先判——不先判的话,一份少了半截的日志
|
||||
会让「步号连续」这类判据在残缺数据上给出「通过」。
|
||||
|
||||
**一条记录都没有时报「无法判定」,不报通过。** 日志文件根本不存在时 `read_log()` 返回的
|
||||
就是这种空读数,而「零条全都解得开」是一句真空成立的话:它读起来像验过了,实际那次跑
|
||||
连一个字节都没落盘,后面每一条判据都建立在一份不存在的日志上。
|
||||
"""
|
||||
if read.bad_lines:
|
||||
return breached(
|
||||
"log_lines_readable",
|
||||
f"第 {list(read.bad_lines)} 行已被换行终结却解不出带 {RECORD_KEY!r} 标签的 JSON 对象",
|
||||
)
|
||||
if not read.payloads:
|
||||
return undetermined(
|
||||
"log_lines_readable",
|
||||
"日志里一条记录都没有(文件不存在,或者一个字节都没落盘),行的完整性无从判起",
|
||||
)
|
||||
return passed("log_lines_readable", f"{len(read.payloads)} 条记录全部解得开")
|
||||
|
||||
|
||||
@@ -482,7 +511,16 @@ def check_audit_unchanged(*, before: Sequence[str], after: Sequence[str]) -> Cri
|
||||
|
||||
时机 B 下库判定状态未知、干净停下,那就不该把被打断的那个动作再执行一遍。多出一条就是
|
||||
重放,少一条或者变了内容说明账被改写过。
|
||||
|
||||
**两边都是空的时候报「无法判定」。** 那代表续跑前后都没有任何副作用被记录过,「一条都没多」
|
||||
就成了一句真空成立的话——而这一档要验的恰恰是「已经落过盘的那次副作用没有再发生一次」,
|
||||
没有那次副作用就没有要验的东西。上一版实跑里这一条正是 0 比 0 通过的。
|
||||
"""
|
||||
if not before and not after:
|
||||
return undetermined(
|
||||
"audit_unchanged_after_resume",
|
||||
"续跑前后审计账都是空的:这次运行里没有任何副作用被记录过,「没多出来」真空成立",
|
||||
)
|
||||
if list(after[: len(before)]) != list(before):
|
||||
return breached(
|
||||
"audit_unchanged_after_resume",
|
||||
@@ -496,6 +534,69 @@ def check_audit_unchanged(*, before: Sequence[str], after: Sequence[str]) -> Cri
|
||||
return passed("audit_unchanged_after_resume", f"续跑前后审计账都是 {len(before)} 条")
|
||||
|
||||
|
||||
def check_audit_prefix_preserved(*, before: Sequence[str], after: Sequence[str]) -> Criterion:
|
||||
"""崩溃时环境账上那几条,续跑之后逐字节还在原处。
|
||||
|
||||
这是日志那侧 `check_crash_prefix_preserved` 在**环境账**上的对应物,而且两者不可互相
|
||||
替代:日志是库自己写的,账是环境写的。库把已经落地的一段轨迹重写掉,日志那条会报;库让
|
||||
环境把已经发生过的副作用重做或抹掉,只有这条会报。
|
||||
|
||||
**时机 A 之前没有这条判据。** 那一档只跑「去重前后条数相等」,而一次把 `evidence.md` 的
|
||||
旧条目改写成别的内容的续跑,条数不变、去重也不变,一路绿灯。
|
||||
"""
|
||||
name = "audit_prefix_preserved"
|
||||
if not before:
|
||||
return undetermined(name, "崩溃时审计账是空的:没有已经落地的副作用可比,前缀比对无从谈起")
|
||||
if len(after) < len(before):
|
||||
return breached(
|
||||
name,
|
||||
f"崩溃时审计账 {len(before)} 条,续跑之后只剩 {len(after)} 条,已经落地的记录被抹掉了",
|
||||
)
|
||||
for index, (old, new) in enumerate(zip(before, after, strict=False)):
|
||||
if old != new:
|
||||
return breached(
|
||||
name, f"崩溃时审计账的第 {index} 条在续跑之后变了内容(按行序计,正文不进报告)"
|
||||
)
|
||||
return passed(name, f"崩溃时审计账的 {len(before)} 条在续跑之后逐字节还在原处")
|
||||
|
||||
|
||||
def check_no_cross_segment_replay(*, before: Sequence[str], after: Sequence[str]) -> Criterion:
|
||||
"""崩溃前已经落地的那次副作用,续跑之后没有再发生一次。
|
||||
|
||||
与 `check_never_action_not_replayed` 的区别在**位置**:那一条在整份账上找重复,找到了也
|
||||
说不出两条分别在崩溃的哪一侧;这一条只看续跑段里有没有哪一条与崩溃前的某一条完全相同,
|
||||
而跨越崩溃边界的重复正是重放的签名——库在状态未知时把一次已经落过盘的写又执行了一遍。
|
||||
|
||||
**它与那一条共享同一个已知假阳性**:模型自己在续跑之后把同一份内容原样又写了一次,看起来
|
||||
一模一样。GovDoc 的 execute 阶段提示词要求写完就停,所以很少发生;真发生了要看轨迹里那两步
|
||||
的步号。这个代价是被接受的,理由与那一条相同。
|
||||
"""
|
||||
name = "no_cross_segment_replay"
|
||||
if not before:
|
||||
return undetermined(
|
||||
name, "崩溃时审计账是空的:没有已经落地的副作用可能被重放,这一条无从判起"
|
||||
)
|
||||
resumed = list(after[len(before) :])
|
||||
if not resumed:
|
||||
return passed(name, f"续跑段里一条审计记录都没有,崩溃前那 {len(before)} 条不可能被重放过")
|
||||
known = {parse_audit_line(line) for line in before}
|
||||
repeats: list[str] = []
|
||||
for line in resumed:
|
||||
entry = parse_audit_line(line)
|
||||
if entry is not None and entry in known:
|
||||
repeats.append(f"{entry[0]} 摘要 {entry[2]}")
|
||||
if repeats:
|
||||
return breached(
|
||||
name,
|
||||
f"续跑段的 {len(resumed)} 条记录里有 {len(repeats)} 条与崩溃前的某一条完全相同:"
|
||||
f"{';'.join(sorted(set(repeats)))}",
|
||||
)
|
||||
return passed(
|
||||
name,
|
||||
f"续跑段新增 {len(resumed)} 条审计记录,没有一条与崩溃前那 {len(before)} 条中的任何一条相同",
|
||||
)
|
||||
|
||||
|
||||
def check_resume_made_progress(*, crashed_steps: int, final_steps: int) -> Criterion:
|
||||
"""时机 A 下续跑真的接着往下跑了。
|
||||
|
||||
@@ -586,8 +687,15 @@ def check_no_env_error_step(read: LogRead) -> Criterion:
|
||||
|
||||
撞预算那两条要的是「干净地撞上限」。中途出过环境故障的话,步数与动作数的账仍然对得上,
|
||||
但这次跑压到的已经不是预算这条路径了。
|
||||
|
||||
**一步都没产出时报「无法判定」**:「零步里没有 env_error」这句话恒为真,而它出现在报告上
|
||||
的样子和「跑了十步都没出故障」一模一样。
|
||||
"""
|
||||
entries = tagged(read, "step_completed")
|
||||
if not entries:
|
||||
return undetermined(
|
||||
"no_env_error_step", "日志里一条步记录都没有,有没有环境故障的步无从判起"
|
||||
)
|
||||
bad: list[int] = []
|
||||
for index, item in enumerate(entries):
|
||||
outcome = item.get("action_outcome")
|
||||
@@ -784,24 +892,39 @@ def check_steps_before_last_all_executed(read: LogRead) -> Criterion:
|
||||
return passed(name, f"环境坏掉之前的 {len(steps) - 1} 步动作状态全是 executed")
|
||||
|
||||
|
||||
def check_env_broken_after_a_full_step(*, broken: bool, executions_before_break: int) -> Criterion:
|
||||
"""动手弄坏环境之前,至少已经完整执行过一次动作。
|
||||
def check_env_broken_after_a_full_step(
|
||||
*, broken: bool, executions_before_break: int, expected_after: int
|
||||
) -> Criterion:
|
||||
"""动手弄坏环境的时机正是声明的那一次:完整执行过 `expected_after` 次动作之后。
|
||||
|
||||
第一次执行之前就把容器打掉的话,验的是「环境起不来」而不是「跑到一半环境不能接着服务
|
||||
了」——前者落在会话初始化上,根本走不到动作执行接缝,而这一类要压的正是那个接缝。
|
||||
|
||||
**动手时机与声明的不一致是击穿,不是无法判定。** 这一条判的是注入器自己:它对外宣称在第
|
||||
`expected_after` 次执行之后动手,整类故障的结论都建立在这句话上。真在别的时刻动手的话,
|
||||
这一类压到的是另一件事,而报告上仍然写着「环境故障那一类通过了」——那正是「判据验的不是
|
||||
它声称要验的东西」这种坏法。判成无法判定则把这件事说成「看不清」,可它看得很清楚:注入器
|
||||
自己记下了动手时它数到几。
|
||||
|
||||
`broken` 为假是另一回事,那一档确实什么都没发生(模型一次动作都没成功执行过),报无法判定。
|
||||
"""
|
||||
name = "env_broken_after_a_full_step"
|
||||
if not broken:
|
||||
return undetermined(
|
||||
name, "这次运行结束时环境一次都没被弄坏过:动作执行没走到该动手的那一次"
|
||||
)
|
||||
if executions_before_break < 1:
|
||||
return undetermined(
|
||||
if executions_before_break != expected_after:
|
||||
return breached(
|
||||
name,
|
||||
f"弄坏环境之前只完整执行过 {executions_before_break} 次动作,"
|
||||
"压到的是初始化而不是执行接缝",
|
||||
f"声明的是完整执行 {expected_after} 次动作之后动手,实际在第 "
|
||||
f"{executions_before_break} 次之后动手:"
|
||||
+ (
|
||||
"压到的是会话初始化而不是动作执行接缝"
|
||||
if executions_before_break < expected_after
|
||||
else "比声明的晚,这一类压到的不是它声称的那个时刻"
|
||||
),
|
||||
)
|
||||
return passed(name, f"完整执行过 {executions_before_break} 次动作之后才把环境弄坏")
|
||||
return passed(name, f"完整执行过 {executions_before_break} 次动作之后才把环境弄坏,与声明一致")
|
||||
|
||||
|
||||
def check_all_steps_parse_failed(read: LogRead) -> Criterion:
|
||||
@@ -855,6 +978,91 @@ def check_cancelled_raised(*, raised: BaseException | None) -> Criterion:
|
||||
return passed("cancelled_error_propagated", "取消之后 await 原样抛出了 CancelledError")
|
||||
|
||||
|
||||
def check_env_quiet_after_cancel(
|
||||
*,
|
||||
dispatched_before: int,
|
||||
dispatched_after: int,
|
||||
completed_before: int,
|
||||
completed_after: int,
|
||||
waited_s: float,
|
||||
) -> Criterion:
|
||||
"""取消之后环境侧不再有动静:动作执行接缝没被再进入,也没有哪次执行又跑完了。
|
||||
|
||||
`dispatched_*` 数的是动作执行接缝被**进入**过几次(由 `TriggeringExecutor` 记),
|
||||
`completed_*` 数的是环境侧确认**跑完**过几次(`AppWorldSession.n_executions`)。前者涨了
|
||||
说明库在取消之后还在往环境派活;后者涨了说明有一次执行在取消之后才落地。两个数分别对应
|
||||
两种不同的坏法,所以一起判。
|
||||
|
||||
**已知看不见的那一半**:`n_executions` 是客户端侧的计数,`tools/soak/appworld.py` 在
|
||||
HTTP 响应返回之后才加一。取消把那个协程掐断了,于是「代码已经发到容器、容器仍然把它跑完
|
||||
并改了环境状态」这种情形在这两个数上都留不下痕迹。容器那侧没有可查的执行计数接口
|
||||
(只有 `/execute`、`/task_completed`、`/evaluate`、`/close`),补上它要改环境层。这条缺口
|
||||
写在模块 docstring 的已知缺口一节里,这里的「通过」只覆盖上面那两种坏法。
|
||||
"""
|
||||
name = "env_quiet_after_cancel"
|
||||
if dispatched_after > dispatched_before:
|
||||
return breached(
|
||||
name,
|
||||
f"取消之后动作执行接缝又被进入了 {dispatched_after - dispatched_before} 次"
|
||||
f"(等了 {waited_s} 秒再数的):库在取消之后还在往环境派活",
|
||||
)
|
||||
if completed_after > completed_before:
|
||||
return breached(
|
||||
name,
|
||||
f"取消之后又有 {completed_after - completed_before} 次执行在环境侧跑完了"
|
||||
f"(等了 {waited_s} 秒再数的)",
|
||||
)
|
||||
if completed_after < completed_before or dispatched_after < dispatched_before:
|
||||
return breached(
|
||||
name,
|
||||
f"取消之后计数倒退了(派活 {dispatched_before}→{dispatched_after},"
|
||||
f"跑完 {completed_before}→{completed_after}),这两个数只该单调不减",
|
||||
)
|
||||
return passed(
|
||||
name,
|
||||
f"取消之后等了 {waited_s} 秒,派活次数仍是 {dispatched_after}、"
|
||||
f"环境侧跑完次数仍是 {completed_after}",
|
||||
)
|
||||
|
||||
|
||||
def check_events_file_intact(path: Path) -> Criterion:
|
||||
"""事件文件的每一行都是完整的一条 JSON 对象。
|
||||
|
||||
取消可能落在事件写盘的途中。**撕裂尾行在这里算击穿,不像日志那侧算「没发生过」**:库有
|
||||
宽限期(`RunRequest.cancel_grace_seconds`),在飞的写有机会收尾,而事件出口每条只写一次
|
||||
`handle.write`。真留下半行,说明取消穿过了一次没有被保护起来的写——那种写下次也会半途而废,
|
||||
而下游读事件流时看到的是一份解不开的文件。
|
||||
"""
|
||||
name = "events_file_intact"
|
||||
if not path.is_file():
|
||||
return undetermined(name, f"没有 {path.name}:这次运行一条事件都没发出过,完整性无从判起")
|
||||
raw = path.read_bytes()
|
||||
if not raw:
|
||||
return undetermined(name, f"{path.name} 是空的,完整性无从判起")
|
||||
chunks = raw.split(b"\n")
|
||||
if chunks[-1].strip():
|
||||
return breached(
|
||||
name,
|
||||
f"{path.name} 末尾有 {len(chunks[-1])} 字节没有被换行终结:取消穿过了一次事件写入的中途",
|
||||
)
|
||||
bad: list[int] = []
|
||||
total = 0
|
||||
for number, chunk in enumerate(chunks[:-1], start=1):
|
||||
if not chunk.strip():
|
||||
continue
|
||||
total += 1
|
||||
try:
|
||||
payload = json.loads(chunk)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
bad.append(number)
|
||||
continue
|
||||
if not isinstance(payload, dict):
|
||||
bad.append(number)
|
||||
if bad:
|
||||
return breached(name, f"{path.name} 的第 {bad} 行不是一个完整的 JSON 对象")
|
||||
return passed(name, f"{path.name} 的 {total} 行全都是完整的 JSON 对象")
|
||||
|
||||
|
||||
def check_lease_returned(*, borrowed: bool, timeout_s: float, pool_size: int) -> Criterion:
|
||||
"""容器租约被归还:取消结束之后还借得到。
|
||||
|
||||
@@ -1230,6 +1438,79 @@ class AlwaysInvalidParser:
|
||||
)
|
||||
|
||||
|
||||
class PromptSizeRecordingClient:
|
||||
"""转发模型调用,并把**每次真正发出去的那份消息**有多少字符按调用序号记下来。
|
||||
|
||||
没有它的话,提示词那两条判据读的全是库写进步记录的 `prompt_chars`——那是库对自己行为的
|
||||
陈述。失败场景很具体:库真的把历史静默截断了,同时仍然把 `prompt_chars` 记成一路不减的
|
||||
自述值,两条判据照样全绿。这与「动作那一维要数环境侧的账、不数库报的步数」是同一条原则,
|
||||
这里把它落在提示词这一维上。
|
||||
|
||||
字符数的口径逐字照 `polyloop._assembly.prompt_chars`:所有消息的所有内容块的文本长度之和。
|
||||
口径不同的话对不上账,而对不上会被判成击穿,那是一次假故障。
|
||||
|
||||
**`parameters()` 原样转发内层的**,不加自己的键:它进参数快照,续跑时那边用的是没包过的
|
||||
客户端,加一个键就是一次假的参数漂移。
|
||||
"""
|
||||
|
||||
__slots__ = ("_inner", "chars_by_call_index")
|
||||
|
||||
def __init__(self, *, inner: object) -> None:
|
||||
self._inner = inner
|
||||
#: 调用序号 → 那次调用真正发出去的字符数。
|
||||
self.chars_by_call_index: dict[int, int] = {}
|
||||
|
||||
def parameters(self) -> Mapping[str, str]:
|
||||
return self._inner.parameters() # type: ignore[attr-defined]
|
||||
|
||||
async def call(self, call: object) -> ModelReply:
|
||||
self.chars_by_call_index[call.call_index] = sum( # type: ignore[attr-defined]
|
||||
len(block.text)
|
||||
for message in call.messages # type: ignore[attr-defined]
|
||||
for block in message.content
|
||||
)
|
||||
return await self._inner.call(call) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def check_prompt_chars_match_what_was_sent(
|
||||
read: LogRead, *, observed: Mapping[int, int]
|
||||
) -> Criterion:
|
||||
"""步记录里的 `prompt_chars` 与库那一步真正发出去的字符数逐步相等。
|
||||
|
||||
这一条是提示词那两条判据的地基:它们都只读 `prompt_chars`,而 `prompt_chars` 是库自己写
|
||||
下的数。库把历史截断了却照旧记一个单调递增的值,那两条都会通过,而这一条会当场对不上账。
|
||||
|
||||
`observed` 由 `PromptSizeRecordingClient` 在调用发生的那一刻记下,键是调用序号;步记录的
|
||||
步号与调用序号是同一个数(`polyloop._recovery` 模块 docstring 那条对齐),所以直接按步号查。
|
||||
"""
|
||||
name = "prompt_chars_match_what_was_sent"
|
||||
steps = _step_payloads(read)
|
||||
if not steps:
|
||||
return undetermined(name, "日志里一条步记录都没有,对不了账")
|
||||
if not observed:
|
||||
return undetermined(name, "一次模型调用都没被记到,对不了账")
|
||||
mismatches: list[str] = []
|
||||
compared = 0
|
||||
for step in steps:
|
||||
index = step.get("step_idx")
|
||||
if not isinstance(index, int) or isinstance(index, bool) or index not in observed:
|
||||
continue
|
||||
logged = step.get("prompt_chars")
|
||||
if not isinstance(logged, int) or isinstance(logged, bool):
|
||||
mismatches.append(f"第 {index} 步的 prompt_chars 不是整数")
|
||||
continue
|
||||
compared += 1
|
||||
if logged != observed[index]:
|
||||
mismatches.append(
|
||||
f"第 {index} 步记的是 {logged} 字符,实际发出去 {observed[index]} 字符"
|
||||
)
|
||||
if mismatches:
|
||||
return breached(name, ";".join(mismatches))
|
||||
if compared == 0:
|
||||
return undetermined(name, "没有一条步记录的步号能和记下来的调用序号对上,对不了账")
|
||||
return passed(name, f"{compared} 步的 prompt_chars 与真正发出去的字符数逐步相等")
|
||||
|
||||
|
||||
class TriggeringModelClient:
|
||||
"""转发模型调用,并在进入第 N 次调用时通知父协程。父协程收到通知立刻取消。
|
||||
|
||||
@@ -1856,6 +2137,8 @@ async def _resume_and_judge(
|
||||
criteria.append(check_step_indices_dense(read))
|
||||
criteria.append(check_intents_settled(read))
|
||||
criteria.append(check_never_action_not_replayed(audit_after))
|
||||
criteria.append(check_audit_prefix_preserved(before=audit_before, after=audit_after))
|
||||
criteria.append(check_no_cross_segment_replay(before=audit_before, after=audit_after))
|
||||
if timing is KillTiming.AFTER_STEP:
|
||||
criteria.append(
|
||||
check_resume_made_progress(
|
||||
@@ -1923,7 +2206,10 @@ async def run_context_overflow_fault(
|
||||
request = replace(base, budget=budget)
|
||||
store = JsonlRunStore(directory=runs_dir)
|
||||
sink = JsonlEventSink(runs_dir / f"{run_id}.events.jsonl")
|
||||
definition = build_crash_definition(model_client=model_client, store=store, sink=sink)
|
||||
# 提示词那几条判据全部读库自己写下的 `prompt_chars`。包一层把真正发出去的规模记下来,
|
||||
# 跑完对账——不对账的话,一次静默截断加一列伪造的自述值可以让它们全绿。
|
||||
recorder = PromptSizeRecordingClient(inner=model_client)
|
||||
definition = build_crash_definition(model_client=recorder, store=store, sink=sink)
|
||||
|
||||
started = time.monotonic()
|
||||
result = await session.run(definition, request)
|
||||
@@ -1936,6 +2222,7 @@ async def run_context_overflow_fault(
|
||||
check_run_finished_present(read),
|
||||
check_stop_reason(read, "context_overflow"),
|
||||
check_at_least_one_step(read),
|
||||
check_prompt_chars_match_what_was_sent(read, observed=recorder.chars_by_call_index),
|
||||
check_prompt_chars_monotonic(read),
|
||||
check_prompt_reached_max_prompt_chars(read),
|
||||
)
|
||||
@@ -2005,6 +2292,12 @@ LEASE_TIMEOUT_S = 120.0
|
||||
#: 等取消触发器的上限。等不到说明这次运行在触发点之前就结束了。
|
||||
TRIGGER_TIMEOUT_S = 300.0
|
||||
|
||||
#: 取消回来之后再等几秒,然后重数一次环境侧的账。见 `check_env_quiet_after_cancel`:立刻就数
|
||||
#: 的话两个数只是同一个瞬间的两份拷贝,什么都验不到。三秒盖得住一次已经发出去的 `/execute`
|
||||
#: 在容器里跑完并返回——AppWorld 那侧单次执行的超时是一百秒,所以盖不住最慢的那一档;这一路
|
||||
#: 的动作都是简短的 API 调用,实测在秒级以内。
|
||||
POST_CANCEL_SETTLE_S = 3.0
|
||||
|
||||
|
||||
def _appworld_definition(
|
||||
*, model_client: object, store: JsonlRunStore, sink: JsonlEventSink, parser: object
|
||||
@@ -2046,6 +2339,7 @@ async def run_cancel_fault(
|
||||
notes: list[str] = []
|
||||
raised: BaseException | None = None
|
||||
env_executions = 0
|
||||
quiet: Criterion | None = None
|
||||
started = time.monotonic()
|
||||
|
||||
async with pool.session(task_id) as handle:
|
||||
@@ -2055,11 +2349,10 @@ async def run_cancel_fault(
|
||||
app_descriptions=app_descriptions,
|
||||
model_binding=APPWORLD_MODEL_BINDING,
|
||||
)
|
||||
probe: TriggeringExecutor | None = None
|
||||
if fault == "cancel_env":
|
||||
request = replace(
|
||||
request,
|
||||
action_executor=TriggeringExecutor(inner=request.action_executor, trigger=trigger), # type: ignore[arg-type]
|
||||
)
|
||||
probe = TriggeringExecutor(inner=request.action_executor, trigger=trigger)
|
||||
request = replace(request, action_executor=probe) # type: ignore[arg-type]
|
||||
client: object = model_client
|
||||
else:
|
||||
# 第 1 次(从 0 数起的第二次)调用时触发:让日志里先有一步完整的记录,取消才落在
|
||||
@@ -2083,6 +2376,19 @@ async def run_cancel_fault(
|
||||
except asyncio.CancelledError as exc:
|
||||
raised = exc
|
||||
env_executions = handle.n_executions
|
||||
if probe is not None:
|
||||
# 取消刚回来就数一次,等一小会儿再数一次。在飞的那次执行要是没被真正掐断,它会在
|
||||
# 这段等待里跑完并把计数推上去——立刻就数的话,两个数只是同一个瞬间的两份拷贝。
|
||||
dispatched_before, completed_before = probe.executions, handle.n_executions
|
||||
await asyncio.sleep(POST_CANCEL_SETTLE_S)
|
||||
quiet = check_env_quiet_after_cancel(
|
||||
dispatched_before=dispatched_before,
|
||||
dispatched_after=probe.executions,
|
||||
completed_before=completed_before,
|
||||
completed_after=handle.n_executions,
|
||||
waited_s=POST_CANCEL_SETTLE_S,
|
||||
)
|
||||
env_executions = handle.n_executions
|
||||
|
||||
wall_ms = int((time.monotonic() - started) * 1000)
|
||||
borrowed = await _can_borrow(pool, task_id, LEASE_TIMEOUT_S)
|
||||
@@ -2093,7 +2399,9 @@ async def run_cancel_fault(
|
||||
check_log_readable(read),
|
||||
check_cancelled_raised(raised=raised),
|
||||
check_stop_reason(read, "cancelled"),
|
||||
check_events_file_intact(runs_dir / f"{run_id}.events.jsonl"),
|
||||
check_lease_returned(borrowed=borrowed, timeout_s=LEASE_TIMEOUT_S, pool_size=1),
|
||||
*((quiet,) if quiet is not None else ()),
|
||||
)
|
||||
write_sidecars(
|
||||
runs_dir=runs_dir,
|
||||
@@ -2326,7 +2634,9 @@ async def run_env_error_fault(
|
||||
),
|
||||
check_steps_before_last_all_executed(read),
|
||||
check_env_broken_after_a_full_step(
|
||||
broken=breaker.broken, executions_before_break=breaker.executions_before_break
|
||||
broken=breaker.broken,
|
||||
executions_before_break=breaker.executions_before_break,
|
||||
expected_after=ENV_ERROR_BREAK_AFTER_EXECUTIONS,
|
||||
),
|
||||
)
|
||||
write_sidecars(
|
||||
@@ -2703,6 +3013,7 @@ __all__ = [
|
||||
"FaultInjectionError",
|
||||
"FaultReport",
|
||||
"JsonlEventSink",
|
||||
"PromptSizeRecordingClient",
|
||||
"KillOutcome",
|
||||
"KillTiming",
|
||||
"LogRead",
|
||||
@@ -2710,11 +3021,14 @@ __all__ = [
|
||||
"build_context_overflow_budget",
|
||||
"check_all_steps_parse_failed",
|
||||
"check_at_least_one_step",
|
||||
"check_audit_prefix_preserved",
|
||||
"check_audit_unchanged",
|
||||
"check_cancelled_raised",
|
||||
"check_crash_prefix_preserved",
|
||||
"check_env_broken_after_a_full_step",
|
||||
"check_env_quiet_after_cancel",
|
||||
"check_env_untouched",
|
||||
"check_events_file_intact",
|
||||
"check_executed_action_count",
|
||||
"check_intents_settled",
|
||||
"check_last_observation_is_synthetic",
|
||||
@@ -2722,9 +3036,11 @@ __all__ = [
|
||||
"check_lease_returned",
|
||||
"check_log_readable",
|
||||
"check_never_action_not_replayed",
|
||||
"check_no_cross_segment_replay",
|
||||
"check_no_env_error_step",
|
||||
"check_prompt_chars_monotonic",
|
||||
"check_prompt_reached_max_prompt_chars",
|
||||
"check_prompt_chars_match_what_was_sent",
|
||||
"check_resume_made_progress",
|
||||
"check_run_finished_present",
|
||||
"check_step_count",
|
||||
|
||||
Reference in New Issue
Block a user