diff --git a/adapters/sqlite/runstore_test.go b/adapters/sqlite/runstore_test.go index 1bb1553..f06a223 100644 --- a/adapters/sqlite/runstore_test.go +++ b/adapters/sqlite/runstore_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "time" @@ -127,7 +128,7 @@ func TestEngineRunsOnSQLiteStore(t *testing.T) { } } backends := squad.NewBackendResolver() - backends.RegisterInstance("script", stubExecutor{result: &squad.ExecResult{Success: false, Logs: "ok", Patches: map[string][]byte{"output.md": []byte(goodReportLite), "notes.md": []byte(goodReportLite), "fields.json": []byte("{}")}}}) + backends.RegisterInstance("script", stubExecutor{result: &squad.ExecResult{Success: true, Logs: "ok", Patches: map[string][]byte{"output.md": []byte(goodReportLite), "notes.md": []byte(goodReportLite), "fields.json": []byte("{}")}}}) blueprints := squad.NewBlueprintRegistry() if err := blueprints.Register(squad.Blueprint{ Key: "demo", Title: "入库演示", Match: []string{"入库演示"}, @@ -185,4 +186,78 @@ func TestEngineRunsOnSQLiteStore(t *testing.T) { } } +// TestEngineFailedResultWithCompletePatchesStaysFailedOnSQLiteStore:F05/U3B 的 +// 不变式在 SQLite-store 集成层同样成立——executor 报失败就是失败,patches +// (交付物)齐全也不得把运行翻成 completed:文件只是诊断信息,不是权威结果。 +func TestEngineFailedResultWithCompletePatchesStaysFailedOnSQLiteStore(t *testing.T) { + runStore, err := OpenSQLiteStore(filepath.Join(t.TempDir(), "runs.db")) + if err != nil { + t.Fatal(err) + } + defer runStore.Close() + dir := t.TempDir() + reg := squad.NewRoleRegistry() + if err := reg.Register(squad.RoleSpec{Role: "researcher", Backend: "script"}); err != nil { + t.Fatal(err) + } + backends := squad.NewBackendResolver() + // 失败结果但携带全套交付物:旧语义会把它提升为成功,U3B 起必须保持失败。 + backends.RegisterInstance("script", stubExecutor{result: &squad.ExecResult{ + Success: false, Logs: "stub-refused", + Patches: map[string][]byte{ + "output.md": []byte(goodReportLite), + "notes.md": []byte(goodReportLite), + "fields.json": []byte("{}"), + }, + }}) + blueprints := squad.NewBlueprintRegistry() + if err := blueprints.Register(squad.Blueprint{ + Key: "faildemo", Title: "失败演示", Match: []string{"失败演示"}, + Steps: []squad.StepSpec{{ID: "s1", Role: "researcher", Title: "调研", Targets: []string{"output.md"}}}, + }); err != nil { + t.Fatal(err) + } + eng, err := squad.NewEngine(squad.Options{ + DataDir: dir, Registry: reg, Blueprints: blueprints, + Planner: squad.NewRulePlanner(), + Backends: backends, Sink: squad.NopSink{}, + Store: runStore, + }) + if err != nil { + t.Fatal(err) + } + res, err := eng.Run(context.Background(), squad.Goal{Text: "做一个失败演示", HitlMode: squad.HitlAuto}, squad.RunOptions{}) + if err != nil { + t.Fatalf("run: %v", err) + } + if res.Status == squad.RunCompleted { + t.Fatalf("failed executor result must not complete (patches are diagnostic only)") + } + if res.Status != squad.RunFailed { + t.Fatalf("status = %s, want failed", res.Status) + } + if res.Plan == nil || res.Plan.Steps[0].Status != "failed" { + t.Fatalf("s1 should be failed, plan = %+v", res.Plan) + } + if !strings.Contains(res.Error, "stub-refused") { + t.Fatalf("failure reason not preserved: %q", res.Error) + } + // 同一断言穿过 Store 端口:SQLite 里落盘的也是失败,而非 completed。 + st, err := runStore.LoadRun(res.WorkDir) + if err != nil { + t.Fatal(err) + } + if st.Plan.Workflow.Status != "failed" { + t.Fatalf("persisted workflow status = %s, want failed", st.Plan.Workflow.Status) + } + if got := st.Plan.Workflow.Steps[0]; got.Status != "failed" || !strings.Contains(got.Error, "stub-refused") { + t.Fatalf("persisted step = %+v", got) + } + // patches 确已物化(文件齐全),依然不翻转状态:权威是执行结果,不是文件。 + sub := filepath.FromSlash(st.Plan.Workflow.Steps[0].SubWorkspace) + if _, err := os.Stat(filepath.Join(res.WorkDir, sub, "output.md")); err != nil { + t.Fatalf("diagnostic patch should still be materialized: %v", err) + } +} + const goodReportLite = "# 测试交付物\n\n## 概述\n\n这是由脚本后端生成的合格交付物,长度超过五十个字符以满足最小质检要求。\n"