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
+283 -11
View File
@@ -6,8 +6,12 @@
违反的输入验它说击穿。
子进程编排那部分拆出了两个纯函数(`should_kill` 判时机到没到、`terminated_prefix` 截已终结
前缀),它们不碰进程也不碰模型,直接单独测。真起子进程那条用的是一个只会往文件里写几行
前缀),它们不碰进程也不碰模型,直接单独测。真起子进程那条用的是一个只会往文件里写几行
JSON 的假子进程,跑完不到两秒。
确定性自杀那条路(`SelfKillingStore`)的测试**把 `os._exit` 换成一个抛哨兵异常的替身**:真调
`os._exit` 会把跑测试的 pytest 进程一起带走,整次收集连一行结果都留不下。替身同时让「死之前
那条记录有没有先写进内层存储」变得可断言——顺序反了的话,崩溃现场就少一条本该已经落地的记录。
"""
from __future__ import annotations
@@ -19,9 +23,24 @@ from pathlib import Path
import pytest
from polyloop.ports import EventKind, InvalidDecision
from polyloop.types import ModelReply, RunResult, StopReason
from polyloop.ports import EventKind, InvalidDecision, RunLog
from polyloop.types import (
ActionOutcome,
ActionStatus,
Intent,
IntentKind,
ModelCallResult,
ModelReply,
ReplayPolicy,
RunFinished,
RunResult,
RunStarted,
StepCompleted,
StepRecord,
StopReason,
)
from tools.soak.faults import (
CRASH_EXIT_CODE,
AlwaysInvalidParser,
CallGuard,
Criterion,
@@ -30,6 +49,7 @@ from tools.soak.faults import (
JsonlEventSink,
KillTiming,
LogRead,
SelfKillingStore,
build_parser,
check_all_steps_parse_failed,
check_audit_unchanged,
@@ -59,6 +79,7 @@ from tools.soak.faults import (
terminated_prefix,
write_sidecars,
)
from tools.soak.scenarios.govdoc import AUDIT_LOG_NAME
RUN_ID = "fault-test-0"
@@ -596,11 +617,195 @@ def test_should_kill_on_empty_log() -> None:
# ---------------------------------------------------------------------------
# 十二、子进程编排:真起一个假子进程
# 十二、确定性自杀:写入落盘之后按时机把自己打死
# ---------------------------------------------------------------------------
class _ExitCalledError(Exception):
"""`os._exit` 的替身抛的哨兵。真调 `os._exit` 会把 pytest 一起带走。"""
def __init__(self, code: int) -> None:
super().__init__(code)
self.code = code
def _exit_sentinel(code: int) -> None:
raise _ExitCalledError(code)
class _RecordingStore:
"""记下每一次写入的假存储。`parameters()` 报的键与 `JsonlRunStore` 一致。"""
def __init__(self) -> None:
self.calls: list[str] = []
def parameters(self) -> dict[str, str]:
return {"kind": "jsonl"}
async def read_log(self, run_id: str) -> RunLog:
self.calls.append("read_log")
return RunLog()
async def write_run_started(self, record: RunStarted) -> None:
self.calls.append("run_started")
async def write_intent(self, record: Intent) -> None:
self.calls.append("intent")
async def write_model_call_result(self, record: ModelCallResult) -> None:
self.calls.append("model_call_result")
async def write_step_completed(self, record: StepCompleted) -> None:
self.calls.append("step_completed")
async def write_run_finished(self, record: RunFinished) -> None:
self.calls.append("run_finished")
def an_intent(policy: ReplayPolicy = ReplayPolicy.NEVER) -> Intent:
return Intent(
run_id=RUN_ID,
kind=IntentKind.MODEL_CALL,
call_index=0,
result_id="r0",
replay_policy=policy,
)
def a_step_completed() -> StepCompleted:
return StepCompleted(
run_id=RUN_ID,
result_id="a0",
action_outcome=ActionOutcome(
status=ActionStatus.EXECUTED,
observation="o",
observation_is_synthetic=False,
env_reported_completion=False,
observation_truncated_chars=0,
),
step=StepRecord(
step_idx=0,
raw_output="x",
content_chars=1,
thinking_chars=0,
action="a",
parse_ok=True,
parse_error=None,
observation="o",
observation_is_synthetic=False,
observation_truncated_chars=0,
prompt_chars=10,
call_id="c0",
step_wall_ms=1,
),
)
def wrapped(
inner: _RecordingStore, *, timing: KillTiming, workspace: Path, audited: bool
) -> SelfKillingStore:
"""包一层,并按需要让工作区的审计账非空。"""
workspace.mkdir(parents=True, exist_ok=True)
if audited:
(workspace / AUDIT_LOG_NAME).write_text("write_note\tevidence.md\taaa\n", encoding="utf-8")
return SelfKillingStore(
inner=inner, timing=timing, workspace=workspace, exit_now=_exit_sentinel
)
async def test_self_kill_after_step_when_the_audit_is_not_empty(tmp_path: Path) -> None:
"""时机 A:步记录落盘之后当场死。**死之前那条记录必须已经写进内层存储**。"""
inner = _RecordingStore()
store = wrapped(inner, timing=KillTiming.AFTER_STEP, workspace=tmp_path, audited=True)
with pytest.raises(_ExitCalledError) as caught:
await store.write_step_completed(a_step_completed())
assert caught.value.code == CRASH_EXIT_CODE
assert inner.calls == ["step_completed"]
async def test_self_kill_after_step_waits_for_a_real_write_note(tmp_path: Path) -> None:
"""审计账还是空的就不死:那时崩掉,最硬那条判据只能真空成立。"""
inner = _RecordingStore()
store = wrapped(inner, timing=KillTiming.AFTER_STEP, workspace=tmp_path, audited=False)
await store.write_step_completed(a_step_completed())
assert inner.calls == ["step_completed"]
async def test_self_kill_after_step_ignores_intents(tmp_path: Path) -> None:
inner = _RecordingStore()
store = wrapped(inner, timing=KillTiming.AFTER_STEP, workspace=tmp_path, audited=True)
await store.write_intent(an_intent())
assert inner.calls == ["intent"]
async def test_self_kill_at_intent_on_a_never_intent(tmp_path: Path) -> None:
inner = _RecordingStore()
store = wrapped(inner, timing=KillTiming.AT_INTENT, workspace=tmp_path, audited=True)
with pytest.raises(_ExitCalledError) as caught:
await store.write_intent(an_intent(ReplayPolicy.NEVER))
assert caught.value.code == CRASH_EXIT_CODE
assert inner.calls == ["intent"]
async def test_self_kill_at_intent_skips_a_safe_intent(tmp_path: Path) -> None:
"""`safe` 那条意图崩了也没用:续跑会重放动作接着跑,停止原因不是状态未知。"""
inner = _RecordingStore()
store = wrapped(inner, timing=KillTiming.AT_INTENT, workspace=tmp_path, audited=True)
await store.write_intent(an_intent(ReplayPolicy.SAFE))
assert inner.calls == ["intent"]
async def test_self_kill_at_intent_waits_for_a_real_write_note(tmp_path: Path) -> None:
inner = _RecordingStore()
store = wrapped(inner, timing=KillTiming.AT_INTENT, workspace=tmp_path, audited=False)
await store.write_intent(an_intent(ReplayPolicy.NEVER))
assert inner.calls == ["intent"]
async def test_self_kill_at_intent_ignores_step_records(tmp_path: Path) -> None:
inner = _RecordingStore()
store = wrapped(inner, timing=KillTiming.AT_INTENT, workspace=tmp_path, audited=True)
await store.write_step_completed(a_step_completed())
assert inner.calls == ["step_completed"]
async def test_self_kill_never_fires_on_the_other_three_writes(tmp_path: Path) -> None:
"""开始、模型调用结果、结束这三处一律不死:它们不是任何一个时机的定义点。"""
for timing in KillTiming:
inner = _RecordingStore()
store = wrapped(inner, timing=timing, workspace=tmp_path, audited=True)
await store.write_run_started(RunStarted(run_id=RUN_ID, parameter_snapshot={}))
await store.write_model_call_result(
ModelCallResult(run_id=RUN_ID, result_id="r0", reply=None, failure="炸了")
)
await store.write_run_finished(
RunFinished(
run_id=RUN_ID,
result=RunResult(
run_id=RUN_ID,
stop_reason=StopReason.STEP_BUDGET,
final_answer=None,
steps=(),
),
)
)
assert await store.read_log(RUN_ID) == RunLog()
assert inner.calls == ["run_started", "model_call_result", "run_finished", "read_log"]
def test_self_kill_forwards_parameters_verbatim(tmp_path: Path) -> None:
"""包装层不许往参数快照里加自己的键:父进程续跑用的是没包过的存储,加了就报假漂移。"""
inner = _RecordingStore()
store = wrapped(inner, timing=KillTiming.AFTER_STEP, workspace=tmp_path, audited=True)
assert store.parameters() == inner.parameters()
# ---------------------------------------------------------------------------
# 十三、子进程编排:真起一个假子进程
# ---------------------------------------------------------------------------
#: 假子进程:往指定文件里逐条追加记录,中间留出足够父进程轮询到的间隔,然后一直睡着不退出。
#: 它不打模型、不起容器,只是一个会按顺序写文件的东西。
#: 它不打模型、不起容器,只是一个会按顺序写文件的东西。走的是外部 SIGKILL 那条兜底路径。
_FAKE_CHILD = """
import json, sys, time
path = sys.argv[1]
@@ -613,6 +818,7 @@ with open(path, "a", encoding="utf-8") as handle:
time.sleep(30)
"""
#: 写完就正常退出,一次都不自杀。
_FAKE_CHILD_EXITS = """
import json, sys
path = sys.argv[1]
@@ -622,8 +828,72 @@ with open(path, "a", encoding="utf-8") as handle:
handle.write(json.dumps(record) + "\\n")
"""
#: 写完就按崩溃退出码把自己打死,模拟 `SelfKillingStore` 那条主路径。
_FAKE_CHILD_SELF_KILL = """
import json, os, sys
path = sys.argv[1]
records = json.loads(sys.argv[2])
with open(path, "a", encoding="utf-8") as handle:
for record in records:
handle.write(json.dumps(record) + "\\n")
handle.flush()
print("子进程说了句话")
sys.stdout.flush()
os._exit(int(sys.argv[3]))
"""
async def test_spawn_and_kill_hits_the_after_step_timing(tmp_path: Path) -> None:
async def test_spawn_and_kill_accepts_a_self_killed_child(tmp_path: Path) -> None:
"""主路径:子进程按崩溃退出码自杀,且日志尾部形态对得上,算命中。"""
log_path = tmp_path / f"{RUN_ID}.jsonl"
records = [intent(), model_result(), step_completed()]
outcome = await spawn_and_kill(
argv=[
sys.executable,
"-c",
_FAKE_CHILD_SELF_KILL,
str(log_path),
json.dumps(records),
str(CRASH_EXIT_CODE),
],
log_path=log_path,
timing=KillTiming.AFTER_STEP,
after_steps=1,
child_log_path=tmp_path / f"{RUN_ID}.child.log",
timeout_s=20.0,
)
assert outcome.hit is True
assert outcome.exit_code == CRASH_EXIT_CODE
assert "自杀" in outcome.reason
assert outcome.steps == 1
assert outcome.snapshot == log_path.read_bytes()
assert "子进程说了句话" in (tmp_path / f"{RUN_ID}.child.log").read_text(encoding="utf-8")
async def test_spawn_and_kill_rejects_a_self_kill_at_the_wrong_tail(tmp_path: Path) -> None:
"""自杀了但尾部形态不对:报没命中,不许因为退出码对就当成命中。"""
log_path = tmp_path / f"{RUN_ID}.jsonl"
records = [intent(), model_result()]
outcome = await spawn_and_kill(
argv=[
sys.executable,
"-c",
_FAKE_CHILD_SELF_KILL,
str(log_path),
json.dumps(records),
str(CRASH_EXIT_CODE),
],
log_path=log_path,
timing=KillTiming.AFTER_STEP,
after_steps=1,
timeout_s=20.0,
)
assert outcome.hit is False
assert "尾部不是时机" in outcome.reason
async def test_spawn_and_kill_still_falls_back_to_sigkill(tmp_path: Path) -> None:
"""兜底路径没删:子进程一直不自杀时,父进程仍然会在尾部形态对上的那一刻杀掉它。"""
log_path = tmp_path / f"{RUN_ID}.jsonl"
records = [intent(), model_result(), step_completed()]
outcome = await spawn_and_kill(
@@ -634,13 +904,14 @@ async def test_spawn_and_kill_hits_the_after_step_timing(tmp_path: Path) -> None
timeout_s=20.0,
)
assert outcome.hit is True
assert "兜底" in outcome.reason
assert outcome.steps == 1
assert outcome.model_calls == 1
assert outcome.snapshot == log_path.read_bytes()
async def test_spawn_and_kill_reports_a_miss_when_the_child_exits(tmp_path: Path) -> None:
"""子进程在命中时机之前就退出了要报出来,不许悄悄当成命中。"""
async def test_spawn_and_kill_reports_a_miss_when_the_child_exits_normally(tmp_path: Path) -> None:
"""子进程正常跑完了要报出来,不许悄悄当成命中。"""
log_path = tmp_path / f"{RUN_ID}.jsonl"
records = [intent(), model_result()]
outcome = await spawn_and_kill(
@@ -651,7 +922,8 @@ async def test_spawn_and_kill_reports_a_miss_when_the_child_exits(tmp_path: Path
timeout_s=20.0,
)
assert outcome.hit is False
assert "退出" in outcome.reason
assert outcome.exit_code == 0
assert f"不是按时机自杀的 {CRASH_EXIT_CODE}" in outcome.reason
async def test_spawn_and_kill_reports_a_miss_on_timeout(tmp_path: Path) -> None:
@@ -664,11 +936,11 @@ async def test_spawn_and_kill_reports_a_miss_on_timeout(tmp_path: Path) -> None:
timeout_s=0.5,
)
assert outcome.hit is False
assert "没命中时机" in outcome.reason
assert "仍没崩在时机" in outcome.reason
# ---------------------------------------------------------------------------
# 十、事件出口、护栏、sidecar、命令行
# 十、事件出口、护栏、sidecar、命令行
# ---------------------------------------------------------------------------