Compare commits

..
Author SHA1 Message Date
weichen 2d27389d0d Fix the stale recovery comment and save-failure memory consistency
Why: review M4 - the recoverInterrupted comment described the removed
auto-retry; batch-2 B-3 - gcFinish/runReviewDriver left in-memory state
contradicting the returned result after terminal-save failures.
Evidence: injected-failure tests assert memory/result consistency;
race suite green (verify-minm4.json, exit=0).
Impact: comment + failure-path consistency; success paths unchanged.
Refs: feature/audit-review-minors-cleanup
2026-10-23 11:31:00 +00:00
weichen 681c303241 Replace the result JSON probe with direct field copy
Why: review M2 - the probe marshalled results to discover fields that
exist at compile time via the replace directive; the indirection could
mask field renames.
Evidence: differential test byte-identical over 10 result shapes;
race suite green (verify-minm2c.json, exit=0).
Impact: behaviorally identical outputs; probe deleted.
Refs: feature/audit-review-minors-cleanup
2026-10-23 09:36:54 +00:00
weichen e0d58e6474 Temporarily disable the pi profile
Why: three consecutive minors-unit attempts failed terminal-record
decoding (exit 0 with changes, no valid record); run dirs preserved
under /tmp/agent-exec-1000. Consistent with the earlier cursor /
antigravity disables recorded in this file and the backend config.
Evidence: /tmp/agent-stack-b2/min-squad{,2,3}-run.json.
Impact: routing only; candidates become codex for this unit.
Refs: Agent-System-Dev feature/audit-review-minors-cleanup
2026-10-23 08:41:10 +00:00
weichen 95b387d722 Pin nested agent-intent and agent-native to the mis-execution fix tips
Why: composition consistency - Dev pins the two containers at their
negation-aware tips; the squad container's nested copies must match
(check-pins).
Evidence: pin only.
Impact: nested gitlink pointer changes.
Refs: feature/recall-misexec-fixes
2026-10-23 03:56:40 +00:00
weichen a5afe9c8cc Pin nested agent-intent and agent-native to the A3 tips
Why: composition consistency - Dev pins agent-intent at the A3
evaluation tip and agent-native at the M5 tip; the squad container's
nested copies must match (check-pins).
Evidence: pin only.
Impact: nested gitlink pointer changes.
Refs: feature/catalog-recall-eval-a3
2026-10-23 01:23:58 +00:00
9 changed files with 347 additions and 56 deletions
+8 -5
View File
@@ -23,10 +23,9 @@ execution:
summary_bytes: 16384
raw_output_bytes: 10485760
max_attempts_per_profile: 3
emergency_profile: codex
roles:
implementation: [cursor, antigravity, pi]
implementation: [cursor, antigravity, pi, codex]
repair: [cursor, antigravity, pi]
test: [pi, antigravity, cursor]
review: [pi, antigravity, cursor, codex]
@@ -36,9 +35,9 @@ profiles:
adapter: cursor
executable: "cursor-agent-stable"
model: auto
# 2026-10-21 临时禁用:U3B 连续两次 fail-stop(首次 TLS 会话前故障 553B 日志、
# 二次 "Cannot use this model: auto",与 Backend 配置已记录的月配额耗尽一致),
# 两次均未建立会话、零改动(现场保留)。恢复时改回 enabled: true。
# Disabled again 2026-10-22 (night): quota returned ("Cannot use
# this model: auto", empty models). pi re-enabled for the minors
# units split into micro-packets (output-truncation mitigation).
enabled: false
antigravity:
@@ -54,6 +53,10 @@ profiles:
adapter: pi
executable: pi
model: auto
# 2026-10-22: four combined-unit attempts failed by final-message
# output truncation cutting the terminal record; the unit is split
# into micro-packets so final messages stay short. Re-disable if
# truncation recurs on short outputs.
enabled: true
codex:
+15 -46
View File
@@ -5,7 +5,6 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"reflect"
"strings"
@@ -217,52 +216,22 @@ func fromNative(res *exec.Result) *squad.ExecResult {
PromptTokens: res.PromptTokens,
CompletionTokens: res.CompletionTokens,
}
// F05/U3b(Squad 侧 contract):
// - native exec.Result 在后续 release 会新增 SideEffects、Canceled、Aborted 字段;
// 本地 deps gitlink 若尚未跟进,从 JSON 探测拿不到这些字段时回落到
// 「legacy reading: side effects = possible」,与既有调用方旧行为一致。
// - 在不影响 ExecResult 默认值的前提下,用 JSON 探测而非硬性修改 deps。
probe, err := probeNativeResultExtras(res)
if err == nil {
out.SideEffects = probe.SideEffects
out.Canceled = probe.Canceled
out.Aborted = probe.Aborted
}
// F05/U3b(Squad 侧 contract):SideEffects / Canceled 直接搬 native 终态,
// 不再走 JSON 探测——native exec.Result 已有这两个字段,探测只是多绕一圈
// 还丢掉类型信息。exec.SideEffects 与 squad.ExecSideEffects 同为 string 底,
// 故显式转换是完整映射;空串仍按「legacy reading: side effects = possible」
// 解释,调用方侧 ExecResult.IsRetryableSideEffectReports() 沿用该回退。
//
// 编译期依赖由 go.mod 的 replace 指令钉住(replace agent-native =>
// ../../deps/agent-native,即 deps/agent-native gitlink):native 一旦改名或
// 改类型,这里立即编译失败,而不是靠运行时探测静默退化成零值。native 侧没有
// Aborted 字段,故 out.Aborted 保持零值(IsCanceled() 仍按 Canceled 生效),
// 与旧探测的产出逐字节一致。
out.SideEffects = squad.ExecSideEffects(res.SideEffects)
out.Canceled = res.Canceled
return out
}
// nativeResultExtras 探测 native exec.Result 上的可选字段(结构化 JSON)。
// 缺省(字段不存在或为零值时)按 legacy reading:SideEffects 空串视为 "possible";
// 调用方侧 ExecResult.IsRetryableSideEffectReports() 已经处理该回退。
type nativeResultExtras struct {
SideEffects squad.ExecSideEffects `json:"side_effects,omitempty"`
Canceled bool `json:"canceled,omitempty"`
Aborted bool `json:"aborted,omitempty"`
}
// probeNativeResultExtras 通过 JSON 探测 native Result 上的可选字段。
// 探测出 cancel/abort/side_effects 任何一个就使用它们;探测失败或全部为零值视为
// legacy reading(SideEffects 空 = possible 兜底)。
func probeNativeResultExtras(res *exec.Result) (nativeResultExtras, error) {
if res == nil {
return nativeResultExtras{}, fmt.Errorf("nil result")
}
v := reflect.ValueOf(res).Elem()
if !v.IsValid() {
return nativeResultExtras{}, fmt.Errorf("invalid result")
}
// 用 JSON 做结构化探测:这一路兼容 deps 升级(增字段)、不增字段两种情形。
data, err := json.Marshal(v.Interface())
if err != nil {
return nativeResultExtras{}, err
}
var out nativeResultExtras
if err := json.Unmarshal(data, &out); err != nil {
return nativeResultExtras{}, err
}
return out, nil
}
// nativeEventExtras 是 native exec.ProcessEvent 上的可选字段:工具调用身份与
// 真实结局(U11b,语义与 native 侧一致:空串 = legacy/未知)。
type nativeEventExtras struct {
@@ -273,8 +242,8 @@ type nativeEventExtras struct {
// probeNativeEventExtras 读取 native 过程帧上的 CallID/Status/ErrorReason。
//
// 与 probeNativeResultExtras 同一取法(可选字段缺席 = legacy 零值),但改用
// 反射按字段名读取而不是 JSON:本地 deps gitlink 上的 exec.ProcessEvent 尚未
// 取法是可选字段缺席 = legacy 零值,用反射按字段名读取而不是直接读字段:本地
// deps gitlink 上的 exec.ProcessEvent 尚未
// 跟进这三个字段(U11a 落地),硬写 ev.CallID 会编译不过;反射让同一份代码
// 两侧都成立——字段缺席取零值(既有行为逐字节不变),字段落地后无需改本文件
// 即生效,也不受 native 是否给字段加 JSON tag 影响。每次过程帧只做三次字段
+83
View File
@@ -1,14 +1,97 @@
package native
import (
"reflect"
"testing"
"time"
"agent-native/exec"
"agent-native/runtime/llm"
"agent-native/runtime/tools"
"agent-squad/squad"
)
// TestFromNativeCopiesTerminalFidelityDirectly:SideEffects/Canceled 直接从
// native exec.Result 搬到 squad.ExecResult(不再经 JSON 探测),native 各终态形状
// 映射结果逐字段相同:缺席字段取零值、native 报出的值原样落地、Aborted 因 native
// 无对应字段而恒为零值(IsCanceled 仍按 Canceled 生效)。
func TestFromNativeCopiesTerminalFidelityDirectly(t *testing.T) {
if fromNative(nil) != nil {
t.Fatal("nil native result must map to nil")
}
patches := map[string][]byte{"main.go": []byte("diff")}
cases := []struct {
name string
in exec.Result
want squad.ExecResult
}{
{
name: "legacy zero result",
in: exec.Result{Success: true},
want: squad.ExecResult{Success: true},
},
{
name: "success with usage and no side-effect report",
in: exec.Result{
Success: true, Patches: patches, Logs: "ok",
PromptTokens: 11, CompletionTokens: 22,
},
want: squad.ExecResult{
Success: true, Patches: patches, Logs: "ok",
PromptTokens: 11, CompletionTokens: 22,
},
},
{
name: "failure with explicit side effects",
in: exec.Result{
ErrorReason: "boom", Retryable: true, SideEffects: exec.SideEffectNone,
},
want: squad.ExecResult{
ErrorReason: "boom", Retryable: true,
SideEffects: squad.ExecSideEffectsNone,
},
},
{
name: "possible side effects reported by native",
in: exec.Result{ErrorReason: "boom", SideEffects: exec.SideEffectPossible},
want: squad.ExecResult{ErrorReason: "boom", SideEffects: squad.ExecSideEffectsPossible},
},
{
name: "canceled run",
in: exec.Result{
ErrorReason: "canceled", Canceled: true, SideEffects: exec.SideEffectNone,
},
want: squad.ExecResult{
ErrorReason: "canceled", Canceled: true,
SideEffects: squad.ExecSideEffectsNone,
},
},
}
for _, tc := range cases {
got := fromNative(&tc.in)
if !reflect.DeepEqual(*got, tc.want) {
t.Fatalf("%s: fromNative must copy directly\n got %+v\nwant %+v", tc.name, *got, tc.want)
}
if got.Aborted {
t.Fatalf("%s: native has no Aborted field, so the mapping must leave it false", tc.name)
}
}
// 契约复核:Canceled 直传后 IsCanceled() 立即为真;side_effects 缺席仍按
// 「possible」读取(IsRetryableSideEffectReports 为假)。
canceled := fromNative(&exec.Result{Canceled: true})
if !canceled.IsCanceled() {
t.Fatal("canceled native result must map to a canceled squad result")
}
legacy := fromNative(&exec.Result{Success: false})
if legacy.IsRetryableSideEffectReports() {
t.Fatal("absent side_effects must keep the legacy possible reading")
}
none := fromNative(&exec.Result{SideEffects: exec.SideEffectNone})
if !none.IsRetryableSideEffectReports() {
t.Fatal("native side_effects=none must map to a retryable side-effect report")
}
}
func TestBuildOptionsBashPermissionGating(t *testing.T) {
base := Config{
Client: &llm.Client{BaseURL: "http://x", APIKey: "k"},
+12 -3
View File
@@ -1069,8 +1069,6 @@ func markStep(st *RunState, id, status, errText string) bool {
}
// recoverInterrupted 处理崩溃时停在 running 的步骤。
// interactive 挂起确认/对账,不自动再执行;auto 允许一次重试;disabled 直接失败。
//
// F05/U3b:恢复是否判定 completed 不再单纯依据「文件齐全」——trusted terminal
// record 是单一权威:仅当 step_terminals.json 中存在该步骤且 Terminal=completed
// 时才能恢复完成(即便文件缺失也照恢复,文件用于诊断);其余情形按 record +
@@ -1078,13 +1076,24 @@ func markStep(st *RunState, id, status, errText string) bool {
//
// - 有 trusted record(completed)+ 缺失文件 -> completed(files 诊断信息)
// - 有 trusted record + 现存文件 -> completed(一致)
// - 有 trusted record + SideEffects=none + 缺失文件 -> 一次重试 pending
// - 有 trusted record + SideEffects=none + 缺失文件 + attempts<2 -> 一次重试 pending
// - 同上但 attempts>=2 -> needs_reconciliation
// - 无 record + 文件齐全 -> needs_reconciliation(不自动 rerun,不假装完成)
// - 无 record + 文件缺失 -> needs_reconciliation
// - record 表明 Canceled/Aborted -> 走 cancel 路径
//
// retention 期间产物缺失若 record SideEffects=possible,禁止无条件 rerun ——
// 必须经人工 reconciliation 才能继续。
//
// HITL 模式不参与上面的 record 判定,只决定「落到 needs_reconciliation」之后
// 如何对外报告;各模式一律不自动重跑、不假装完成:
//
// - interactive -> 合成 HITL 挂起(retry / skip / abort)等待人工对账
// - auto / disabled / 缺省 -> run 置 RunWaitingReconciliation 后返回等人工对账
//
// 换言之没有「auto 免确认重试」这条路径:上面的「一次重试」只来自 record 表明
// SideEffects=none 且产物缺失,与 HITL 模式无关;auto 与 disabled 也不直接失败,
// 一律停在可对账的状态上。
func (e *SquadEngine) recoverInterrupted(ctx context.Context, st *RunState) (*RunResult, error) {
terminals := loadStepTerminalsForWorkDir(st.Meta.WorkDir)
drv := &flowDriver{opts: e.deps}
+6
View File
@@ -341,6 +341,12 @@ func (e *SquadEngine) gcFinish(st *RunState, drv *flowDriver) *RunResult {
st.Meta.CompletedAt = &now
// F11c:终态落盘先于返回 RunCompleted——写不进去就不能报完成。
if serr := st.save(); serr != nil {
// B-3:返回 failed 就不能把内存态留在 completed(含 wf.Status)——
// 改写成 failed,避免内存说成功、结果说失败的自相矛盾。
st.Meta.Status = string(RunFailed)
wfFailed := st.Workflow()
wfFailed.Status = string(RunFailed)
st.setWorkflow(wfFailed)
st.mu.Unlock()
msg := persistenceFailureText("save terminal run state", serr)
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
+6
View File
@@ -328,6 +328,12 @@ func (e *SquadEngine) runReviewDriver(ctx context.Context, st *RunState) (*RunRe
st.Meta.CompletedAt = &now
// F11c:终态落盘先于返回 RunCompleted——写不进去就不能报完成。
if serr := st.save(); serr != nil {
// B-3:返回值是 failed,内存态就不许再留 completed/degraded——改写成
// failed(含 wf.Status),避免内存说成功、结果说失败的自相矛盾。
st.Meta.Status = string(RunFailed)
wfFailed := st.Workflow()
wfFailed.Status = string(RunFailed)
st.setWorkflow(wfFailed)
st.mu.Unlock()
msg := persistenceFailureText("save terminal run state", serr)
return &RunResult{RunID: st.Meta.ID, Status: RunFailed, Plan: planPtr(st),
+215
View File
@@ -0,0 +1,215 @@
package squad
import (
"context"
"errors"
"path/filepath"
"strings"
"sync"
"testing"
)
// terminal_state_consistency_test.go — B-3:终态落盘失败后,内存态与返回值必须一致。
//
// 驱动收口时先把 st.Meta.Status / wf.Status 写成终态再 save;save 失败则返回
// RunFailed。若内存态还留在刚写的 completed/degraded,内存里就留下一条与
// 返回值矛盾的「成功」状态——后续读内存/Inspect/事件的调用方都会被误导。
//
// 这里用 captureStore 抓下 SaveRun 收到的 *RunState(引擎内存态的同一对象),
// Run 返回后直接断言其状态等于返回值(失败的正是那次写盘,读盘拿不到内存态)。
// captureStore 包装 faultStore:注入终态落盘失败,并留住最后一次 SaveRun 的
// RunState 指针供断言。
type captureStore struct {
*faultStore
mu sync.Mutex
saves int
last *RunState
}
func (c *captureStore) SaveRun(st *RunState) error {
c.mu.Lock()
c.saves++
c.last = st
c.mu.Unlock()
return c.faultStore.SaveRun(st)
}
func (c *captureStore) captured() *RunState {
c.mu.Lock()
defer c.mu.Unlock()
return c.last
}
// assertMemoryMatchesResult 断言「内存态 == 返回值」,返回的 plan 也不得谎报完成。
func assertMemoryMatchesResult(t *testing.T, store *captureStore, res *RunResult) {
t.Helper()
mem := store.captured()
if mem == nil {
t.Fatal("SaveRun 从未被调用:没走到终态落盘,断言落空")
}
if RunStatus(mem.Meta.Status) != res.Status {
t.Fatalf("内存 meta.status = %q,返回值 = %q(内存与结果矛盾)",
mem.Meta.Status, res.Status)
}
if RunStatus(mem.Workflow().Status) != res.Status {
t.Fatalf("内存 workflow.status = %q,返回值 = %q(内存与结果矛盾)",
mem.Workflow().Status, res.Status)
}
if res.Plan != nil && res.Plan.Status != string(res.Status) {
t.Fatalf("返回 plan.status = %q,返回值 status = %q(plan 仍谎报完成)",
res.Plan.Status, res.Status)
}
}
// newTopologyEngineWithStore 装配带指定 Store 的引擎(拓扑驱动共用)。
func newTopologyEngineWithStore(t *testing.T, planner Planner, be Executor, roles []RoleSpec, store Store) *SquadEngine {
t.Helper()
dir := t.TempDir()
reg := NewRoleRegistry()
for _, r := range roles {
if err := reg.Register(r); err != nil {
t.Fatal(err)
}
}
backends := NewBackendResolver()
backends.RegisterInstance(be.Name(), be)
engine, err := NewEngine(Options{DataDir: dir, Registry: reg, Planner: planner,
Backends: backends, Sink: NopSink{}, Store: store})
if err != nil {
t.Fatal(err)
}
return engine
}
// newTerminalSaveFaultStore 注入「终态写盘失败」的 Store。
func newTerminalSaveFaultStore() *captureStore {
return &captureStore{faultStore: &faultStore{Store: FileStore{}, failTerminalSave: true}}
}
// assertFailedWithPersistenceCause 终态写盘失败的共同外观:返回 failed 且原因可辨。
func assertFailedWithPersistenceCause(t *testing.T, res *RunResult, err error) {
t.Helper()
if err != nil {
t.Fatalf("落盘失败不得当作基础设施错误中止运行:%v", err)
}
if res == nil || res.Status != RunFailed {
t.Fatalf("终态写盘失败必须返回 failed,got %+v", res)
}
if !strings.Contains(res.Error, persistenceFailurePrefix) {
t.Fatalf("失败原因未标注为落盘失败:%q", res.Error)
}
}
// ---- group_chat 收口 ----
// TestGroupChatTerminalSaveFailureAlignsMemoryWithResult:gcFinish 终态写盘失败
// 返回 failed 后,内存态不得停留在 completed。
func TestGroupChatTerminalSaveFailureAlignsMemoryWithResult(t *testing.T) {
be := &gcScriptBackend{messages: map[string]string{
"turn_01_researcher": "第一句",
"turn_02_writer": "同意收口 [close]",
}}
store := newTerminalSaveFaultStore()
engine := newTopologyEngineWithStore(t, mockScriptPlanner(
StepSpec{ID: "p1", Role: "researcher", Title: "参与者甲"},
StepSpec{ID: "p2", Role: "writer", Title: "参与者乙"},
), be, []RoleSpec{
{Role: "researcher", Name: "甲", Backend: "gc-script"},
{Role: "writer", Name: "乙", Backend: "gc-script"},
}, store)
res, err := engine.Run(context.Background(), Goal{Text: "群聊"},
RunOptions{Topology: TopologyGroupChat, GroupChatMaxTurns: 8})
assertFailedWithPersistenceCause(t, res, err)
assertMemoryMatchesResult(t, store, res)
}
// ---- review 收口(completed 档)----
// b3ReviewFindings 一条可核实的问题(≥50 字符,过最小质检)。
const b3ReviewFindings = `[{"location":"第2段","severity":"medium","issue":"结论缺少数据来源","evidence":"正文第2段直接给出结论,未标注任何来源"}]`
// b3ReviewBackend finder 目录回放 findings.json,verify_* 目录回放 PASS 判定。
type b3ReviewBackend struct{}
func (b *b3ReviewBackend) Name() string { return "b3-review" }
func (b *b3ReviewBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
if strings.HasPrefix(key, "verify_") {
return writeOne(req.WorkDir, "verdict.md", "核对原文确实没有来源。\nVERDICT: PASS"), nil
}
return writeOne(req.WorkDir, "findings.json", b3ReviewFindings), nil
}
// TestReviewTerminalSaveFailureAlignsMemoryWithResult:runReviewDriver 终态写盘
// 失败返回 failed 后,内存态不得停留在 completed。
func TestReviewTerminalSaveFailureAlignsMemoryWithResult(t *testing.T) {
planner := mockScriptPlanner(
StepSpec{ID: "f1", Role: "researcher", Title: "评审一", Targets: []string{"findings.json"}},
)
planner.Agents = b3ReviewRoles("b3-review")
store := newTerminalSaveFaultStore()
engine := newTopologyEngineWithStore(t, planner, &b3ReviewBackend{}, b3ReviewRoles("b3-review"), store)
res, err := engine.Run(context.Background(), Goal{Text: "评审材料"},
RunOptions{Topology: TopologyReview, ReviewTarget: "材料正文"})
assertFailedWithPersistenceCause(t, res, err)
assertMemoryMatchesResult(t, store, res)
}
// ---- review 收口(degraded 档)----
// b3FailingReviewBackend finder 目录执行失败(非关键步骤 → 收口降级)。
type b3FailingReviewBackend struct{}
func (b *b3FailingReviewBackend) Name() string { return "b3-review-fail" }
func (b *b3FailingReviewBackend) Execute(_ context.Context, req ExecRequest) (*ExecResult, error) {
key := filepath.Base(filepath.Clean(req.WorkDir))
if strings.HasPrefix(key, "verify_") {
return writeOne(req.WorkDir, "verdict.md", "无法核实。\nVERDICT: FAIL"), nil
}
// SideEffects=possible + err:按稳定失败处理,不重试。
return &ExecResult{Success: false, ErrorReason: "finder 后端失败",
SideEffects: ExecSideEffectsPossible}, errors.New("finder 后端失败")
}
// b3ReviewRoles review 拓扑的编制(一个 finder + 一个 verify)。
func b3ReviewRoles(backend string) []RoleSpec {
return []RoleSpec{
{Role: "researcher", Name: "评审一", Backend: backend},
{Role: "verifier", Name: "核实", Backend: backend},
}
}
// TestReviewDegradedFinishSaveFailureAlignsMemoryWithResult:finder 失败时收口
// 本该写 degraded;写盘失败后内存态必须改写为 failed,不得停在 degraded。
func TestReviewDegradedFinishSaveFailureAlignsMemoryWithResult(t *testing.T) {
crit := false // 非关键 finder:失败降级继续,收口写 degraded
planner := mockScriptPlanner(
StepSpec{ID: "f1", Role: "researcher", Title: "评审一",
Targets: []string{"findings.json"}, Critical: &crit},
)
planner.Agents = b3ReviewRoles("b3-review-fail")
// 对照组:同一夹具在落盘正常时收口 degraded——钉住本测试真的走到 degraded 档。
okEngine := newTopologyEngineWithStore(t, planner, &b3FailingReviewBackend{},
b3ReviewRoles("b3-review-fail"), FileStore{})
okRes, err := okEngine.Run(context.Background(), Goal{Text: "评审材料"},
RunOptions{Topology: TopologyReview, ReviewTarget: "材料正文"})
if err != nil {
t.Fatal(err)
}
if okRes.Status != RunDegraded {
t.Fatalf("对照组(落盘正常)应降级收口 degraded,got %s %q", okRes.Status, okRes.Error)
}
// 注入终态写盘失败:返回 failed,内存态同步为 failed。
store := newTerminalSaveFaultStore()
engine := newTopologyEngineWithStore(t, planner, &b3FailingReviewBackend{},
b3ReviewRoles("b3-review-fail"), store)
res, err := engine.Run(context.Background(), Goal{Text: "评审材料"},
RunOptions{Topology: TopologyReview, ReviewTarget: "材料正文"})
assertFailedWithPersistenceCause(t, res, err)
assertMemoryMatchesResult(t, store, res)
}