Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d27389d0d | ||
|
|
681c303241 | ||
|
|
e0d58e6474 | ||
|
|
95b387d722 | ||
|
|
a5afe9c8cc |
+8
-5
@@ -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
@@ -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 影响。每次过程帧只做三次字段
|
||||
|
||||
@@ -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"},
|
||||
|
||||
Vendored
+1
-1
Submodule deps/agent-intent updated: 765a7e7177...7831228114
Vendored
+1
-1
Submodule deps/agent-native updated: f2fb9ffa6d...4183ea77d9
+12
-3
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user