From e04829ee8edb56a7199fb386b41b5464876434b1 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 19:09:48 +0000 Subject: [PATCH 01/13] fix(template): embed official doc templates for container cwd App images have no templates/ checkout, so GetDocTemplate fell back to a two-section stub and prelim_design lost its real contract. Co-authored-by: Cursor --- deploy/app/Dockerfile | 2 ++ internal/template/loader.go | 8 ++++++++ internal/template/loader_test.go | 29 +++++++++++++++++++++++++++++ templates/embed.go | 9 +++++++++ 4 files changed, 48 insertions(+) create mode 100644 templates/embed.go diff --git a/deploy/app/Dockerfile b/deploy/app/Dockerfile index eafd7510..b582061d 100644 --- a/deploy/app/Dockerfile +++ b/deploy/app/Dockerfile @@ -30,6 +30,8 @@ WORKDIR /app COPY --from=go-builder /server /app/server COPY --from=web-builder /web/dist /app/web/dist COPY deploy/postgres/goose /app/deploy/postgres/goose +COPY templates /app/templates +COPY configs /app/configs ENV DATA_DIR=/app/data \ HTTP_HOST=0.0.0.0 \ diff --git a/internal/template/loader.go b/internal/template/loader.go index 0eed3ed8..2d825343 100644 --- a/internal/template/loader.go +++ b/internal/template/loader.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" "strings" + + "chat-agent-system/templates" ) func findProjectRoot() string { @@ -25,6 +27,12 @@ func findProjectRoot() string { func GetDocTemplate(name string) (string, error) { name = strings.TrimSuffix(name, ".md") + if name == "" || strings.Contains(name, "..") || strings.ContainsAny(name, `/\`) { + return "", fmt.Errorf("read doc template: invalid name %q", name) + } + if b, err := templates.DocTemplates.ReadFile("docs/" + name + ".md"); err == nil && len(strings.TrimSpace(string(b))) > 0 { + return string(b), nil + } root := findProjectRoot() path := filepath.Join(root, "templates", "docs", name+".md") b, err := os.ReadFile(path) diff --git a/internal/template/loader_test.go b/internal/template/loader_test.go index 0b174c74..8f22e7ad 100644 --- a/internal/template/loader_test.go +++ b/internal/template/loader_test.go @@ -38,3 +38,32 @@ func TestLoadDocTemplatesAndAgents(t *testing.T) { t.Errorf("render failed to replace customer_name") } } + +func TestGetDocTemplate_PrelimDesignOfficialNotStub(t *testing.T) { + tmpl, err := template.GetDocTemplate("prelim_design") + if err != nil { + t.Fatalf("prelim_design must load from embed/disk: %v", err) + } + if !strings.Contains(tmpl, "系统初步设计方案") { + t.Fatalf("official prelim_design title missing") + } + if !strings.Contains(tmpl, "```mermaid") { + t.Fatalf("official prelim_design must keep mermaid context diagram") + } + if strings.Contains(tmpl, "## 1. 结论") && strings.Contains(tmpl, "待确认") && !strings.Contains(tmpl, "子系统划分") { + t.Fatalf("must not fall back to two-section stub") + } +} + +func TestGetDocTemplate_WorksWithoutDiskCheckout(t *testing.T) { + t.Chdir(t.TempDir()) + for _, key := range []string{"intake", "prelim_design", "research"} { + tmpl, err := template.GetDocTemplate(key) + if err != nil { + t.Fatalf("%s must resolve from embed when cwd has no templates/: %v", key, err) + } + if strings.TrimSpace(tmpl) == "" { + t.Fatalf("%s embed was empty", key) + } + } +} diff --git a/templates/embed.go b/templates/embed.go new file mode 100644 index 00000000..ce0204a7 --- /dev/null +++ b/templates/embed.go @@ -0,0 +1,9 @@ +package templates + +import "embed" + +// DocTemplates holds official workflow markdown so containers without a +// checkout of templates/docs still serve the real contract, not a stub. +// +//go:embed docs/*.md +var DocTemplates embed.FS From 4a97ff5dd165cbacf7ddf5ff86be7ba856937b07 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 19:09:48 +0000 Subject: [PATCH 02/13] fix(agent): map runner patch paths and require real targets Normalize absolute, ./, and duplicated step prefixes onto output.md, and fail closed when the runner reports ok without a non-empty deliverable. Co-authored-by: Cursor --- internal/agent/backend_test.go | 85 +++++++++++++++++++++++ internal/agent/deliverable.go | 104 +++++++++++++++++++++++++++++ internal/agent/deliverable_test.go | 83 +++++++++++++++++++++++ internal/agent/piagent.go | 36 +++++----- internal/squad/workflow.go | 17 +++-- 5 files changed, 297 insertions(+), 28 deletions(-) create mode 100644 internal/agent/deliverable.go create mode 100644 internal/agent/deliverable_test.go diff --git a/internal/agent/backend_test.go b/internal/agent/backend_test.go index ffaced36..cb082435 100644 --- a/internal/agent/backend_test.go +++ b/internal/agent/backend_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -101,3 +102,87 @@ func TestPiAgentRemoteRunner(t *testing.T) { t.Errorf("unexpected local file content: %s", string(localContent)) } } + +func TestPiAgentRemoteRunner_PathAliasesAndMissing(t *testing.T) { + run := func(mode, want string, expectOK bool) { + t.Helper() + dir := filepath.Join(t.TempDir(), "tasks", "01_draft") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/run" { + http.NotFound(w, r) + return + } + var patches []map[string]string + switch mode { + case "abs": + patches = []map[string]string{{ + "rel_path": filepath.Join(dir, "output.md"), + "content": "# from absolute rel_path\n", + }} + case "nested": + patches = []map[string]string{{ + "rel_path": "tasks/01_draft/output.md", + "content": "# from nested rel_path\n", + }} + case "dot": + patches = []map[string]string{{ + "rel_path": "./output.md", + "content": "# from dot rel_path\n", + }} + default: + patches = []map[string]string{{ + "rel_path": "execution.log", + "content": "Outcome: SUCCESS\n", + }} + } + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "patches": patches}) + })) + defer ts.Close() + + cfg := &config.Config{ + AgentBackend: "pi-agent", + PiAgentRunnerURL: ts.URL, + PiAgentModel: "MiniMax-M3", + PiAgentProvider: "minimax", + AgentTimeout: 5 * time.Second, + } + res, err := agent.NewBackend("pi-agent", cfg).Execute(context.Background(), agent.Request{ + WorkDir: dir, + Prompt: "draft", + Targets: []string{"output.md"}, + }) + if err != nil { + t.Fatalf("mode %s unexpected err: %v", mode, err) + } + if expectOK { + if !res.Success { + t.Fatalf("mode %s want success, got %s", mode, res.ErrorReason) + } + got, rerr := os.ReadFile(filepath.Join(dir, "output.md")) + if rerr != nil { + t.Fatalf("mode %s missing file: %v", mode, rerr) + } + if string(got) != want { + t.Fatalf("mode %s content %q", mode, got) + } + return + } + if res.Success { + t.Fatalf("mode %s must fail closed", mode) + } + if !strings.Contains(res.ErrorReason, "missing target deliverable output.md") { + t.Fatalf("mode %s reason %q", mode, res.ErrorReason) + } + if _, serr := os.Stat(filepath.Join(dir, "output.md")); serr == nil { + t.Fatalf("mode %s must not invent output.md", mode) + } + } + + run("abs", "# from absolute rel_path\n", true) + run("nested", "# from nested rel_path\n", true) + run("dot", "# from dot rel_path\n", true) + run("missing", "", false) +} diff --git a/internal/agent/deliverable.go b/internal/agent/deliverable.go new file mode 100644 index 00000000..13fe7748 --- /dev/null +++ b/internal/agent/deliverable.go @@ -0,0 +1,104 @@ +package agent + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" +) + +func slashClean(p string) string { + return filepath.ToSlash(filepath.Clean(p)) +} + +// MatchDeliverableRel reports whether a runner rel_path refers to target +// inside workDir. Accepts output.md, ./output.md, absolute paths under workDir, +// and a duplicated step prefix (tasks/01_draft/output.md when workDir already +// ends with tasks/01_draft). Paths that escape workDir never match. +func MatchDeliverableRel(workDir, relPath, target string) bool { + target = slashClean(strings.TrimSpace(target)) + if target == "" || target == "." || strings.HasPrefix(target, "../") || strings.Contains(target, "..") { + return false + } + raw := strings.TrimSpace(relPath) + if raw == "" { + return false + } + abs := raw + if !filepath.IsAbs(raw) { + abs = filepath.Join(workDir, raw) + } + abs = filepath.Clean(abs) + rel, err := filepath.Rel(filepath.Clean(workDir), abs) + if err != nil { + return false + } + rel = slashClean(rel) + if rel == ".." || strings.HasPrefix(rel, "../") { + return false + } + if rel == target { + return true + } + if filepath.Base(rel) != filepath.Base(target) { + return false + } + prefix := slashClean(filepath.Dir(rel)) + wd := slashClean(workDir) + return prefix != "." && (strings.HasSuffix(wd, "/"+prefix) || strings.HasSuffix(wd, prefix)) +} + +// CollectTargetPatches maps incoming runner keys onto canonical target names +// and also accepts a non-empty file already at workDir/target. Empty bodies +// do not count — callers must not invent a deliverable. +func CollectTargetPatches(workDir string, targets []string, incoming map[string][]byte) map[string][]byte { + out := make(map[string][]byte, len(targets)) + for _, t := range targets { + t = strings.TrimSpace(t) + if t == "" { + continue + } + for k, v := range incoming { + if len(bytes.TrimSpace(v)) == 0 { + continue + } + if MatchDeliverableRel(workDir, k, t) { + out[t] = v + break + } + } + if _, ok := out[t]; ok { + continue + } + p := filepath.Join(workDir, t) + if b, err := os.ReadFile(p); err == nil && len(bytes.TrimSpace(b)) > 0 { + out[t] = b + } + } + return out +} + +// MaterializeTargets writes collected non-empty target bodies to workDir/. +// Returns a missing-target error without writing a stub if any required file is absent. +func MaterializeTargets(workDir string, targets []string, incoming map[string][]byte) (map[string][]byte, error) { + collected := CollectTargetPatches(workDir, targets, incoming) + for _, t := range targets { + t = strings.TrimSpace(t) + if t == "" { + continue + } + body, ok := collected[t] + if !ok || len(bytes.TrimSpace(body)) == 0 { + return collected, fmt.Errorf("missing target deliverable %s", t) + } + dest := filepath.Join(workDir, filepath.FromSlash(t)) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return collected, err + } + if err := os.WriteFile(dest, body, 0o644); err != nil { + return collected, err + } + } + return collected, nil +} diff --git a/internal/agent/deliverable_test.go b/internal/agent/deliverable_test.go new file mode 100644 index 00000000..2b4ea7dc --- /dev/null +++ b/internal/agent/deliverable_test.go @@ -0,0 +1,83 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMatchDeliverableRel_Aliases(t *testing.T) { + workDir := filepath.Join(string(filepath.Separator), "app", "data", "workspaces", "bizdev", "prelim_x", "tasks", "01_draft") + abs := filepath.Join(workDir, "output.md") + cases := []struct { + rel string + want bool + }{ + {"output.md", true}, + {"./output.md", true}, + {abs, true}, + {"tasks/01_draft/output.md", true}, + {"execution.log", false}, + {"../output.md", false}, + {"/etc/passwd", false}, + {"tasks/02_review/output.md", false}, + } + for _, tc := range cases { + if got := MatchDeliverableRel(workDir, tc.rel, "output.md"); got != tc.want { + t.Errorf("MatchDeliverableRel(%q) = %v, want %v", tc.rel, got, tc.want) + } + } +} + +func TestMaterializeTargets_MapsAbsoluteAndNested(t *testing.T) { + workDir := filepath.Join(t.TempDir(), "tasks", "01_draft") + if err := os.MkdirAll(workDir, 0o755); err != nil { + t.Fatal(err) + } + body := []byte("# 系统初步设计方案\n\n真实 runner 产物\n") + incoming := map[string][]byte{ + filepath.Join(workDir, "output.md"): body, + } + got, err := MaterializeTargets(workDir, []string{"output.md"}, incoming) + if err != nil { + t.Fatalf("absolute rel_path must materialize: %v", err) + } + if string(got["output.md"]) != string(body) { + t.Fatalf("canonical patch missing") + } + onDisk, err := os.ReadFile(filepath.Join(workDir, "output.md")) + if err != nil { + t.Fatal(err) + } + if string(onDisk) != string(body) { + t.Fatalf("disk output.md mismatch") + } + + nestedDir := filepath.Join(t.TempDir(), "ws", "tasks", "01_draft") + if err := os.MkdirAll(nestedDir, 0o755); err != nil { + t.Fatal(err) + } + if _, err := MaterializeTargets(nestedDir, []string{"output.md"}, map[string][]byte{ + "tasks/01_draft/output.md": []byte("# nested alias\n"), + }); err != nil { + t.Fatalf("duplicated step prefix must map: %v", err) + } +} + +func TestMaterializeTargets_MissingAndEmptyFail(t *testing.T) { + workDir := t.TempDir() + if _, err := MaterializeTargets(workDir, []string{"output.md"}, map[string][]byte{ + "execution.log": []byte("Outcome: SUCCESS\n"), + }); err == nil || !strings.Contains(err.Error(), "missing target deliverable output.md") { + t.Fatalf("must fail closed without inventing output.md, got %v", err) + } + if _, err := os.Stat(filepath.Join(workDir, "output.md")); err == nil { + t.Fatal("must not write a stub output.md") + } + if _, err := MaterializeTargets(workDir, []string{"output.md"}, map[string][]byte{ + "output.md": []byte(" \n"), + }); err == nil { + t.Fatal("whitespace-only body must not count as a deliverable") + } +} diff --git a/internal/agent/piagent.go b/internal/agent/piagent.go index 8d91d50e..389a4856 100644 --- a/internal/agent/piagent.go +++ b/internal/agent/piagent.go @@ -106,12 +106,14 @@ func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, err }, nil } - patches := make(map[string][]byte) - for _, t := range req.Targets { - p := filepath.Join(workDir, t) - if b, err := os.ReadFile(p); err == nil { - patches[t] = b - } + patches, err := MaterializeTargets(workDir, req.Targets, nil) + if err != nil { + return &Result{ + Success: false, + ErrorReason: err.Error(), + Patches: patches, + Logs: stdout.String(), + }, nil } return &Result{ @@ -182,21 +184,17 @@ func (p *PiAgentBackend) executeRemote(ctx context.Context, runnerURL string, re }, nil } - patches := make(map[string][]byte) + incoming := make(map[string][]byte, len(remoteResp.Patches)) for _, pt := range remoteResp.Patches { - patches[pt.RelPath] = []byte(pt.Content) - localPath := filepath.Join(workDir, pt.RelPath) - _ = os.MkdirAll(filepath.Dir(localPath), 0755) - _ = os.WriteFile(localPath, []byte(pt.Content), 0644) + incoming[pt.RelPath] = []byte(pt.Content) } - - for _, t := range req.Targets { - if _, ok := patches[t]; !ok { - targetPath := filepath.Join(workDir, t) - if b, err := os.ReadFile(targetPath); err == nil { - patches[t] = b - } - } + patches, matErr := MaterializeTargets(workDir, req.Targets, incoming) + if matErr != nil { + return &Result{ + Success: false, + ErrorReason: matErr.Error(), + Patches: patches, + }, nil } return &Result{ diff --git a/internal/squad/workflow.go b/internal/squad/workflow.go index a38a379c..91f6170a 100644 --- a/internal/squad/workflow.go +++ b/internal/squad/workflow.go @@ -583,16 +583,15 @@ func (d *WorkflowDriver) runOneStep( return fmt.Errorf("step %s failed: %s", step.ID, errMsg) } - // 2. 确认 deliverable 文件落盘 - targetPath := filepath.Join(subDir, deliverableName) - if _, err := os.Stat(targetPath); err != nil { - // 若后端补丁在内存中返回,写入磁盘 - if res != nil && len(res.Patches[deliverableName]) > 0 { - _ = os.WriteFile(targetPath, res.Patches[deliverableName], 0o644) - } else { - return fmt.Errorf("step %s missing target deliverable %s", step.ID, deliverableName) - } + // 2. 确认 deliverable 文件落盘(路径别名归一化;缺产物不得编造) + incoming := map[string][]byte{} + if res != nil { + incoming = res.Patches } + if _, matErr := agent.MaterializeTargets(subDir, []string{deliverableName}, incoming); matErr != nil { + return fmt.Errorf("step %s %w", step.ID, matErr) + } + targetPath := filepath.Join(subDir, deliverableName) // 3. 检查是否有 fields.json 并合并入 blackboard.json fieldsPath := filepath.Join(subDir, "fields.json") From 333c7a604f908645b11ed97c604c70bd0de0b865 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 19:09:48 +0000 Subject: [PATCH 03/13] fix(pi-agent): do not settle success without a deliverable agent_settled after exhausted retries is not success; missing or empty target files must return ok:false instead of an empty patch list. Co-authored-by: Cursor --- deploy/pi-agent/runner.mjs | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/deploy/pi-agent/runner.mjs b/deploy/pi-agent/runner.mjs index bc300d40..845d28b7 100755 --- a/deploy/pi-agent/runner.mjs +++ b/deploy/pi-agent/runner.mjs @@ -160,6 +160,7 @@ async function handleRun(body) { let runId = ""; let lastErr = ""; let succeeded = false; + let retryFailed = false; console.log(`[runner] Starting pi run in workspace: ${workspace}, model: ${model}`); @@ -201,8 +202,17 @@ async function handleRun(body) { const t = ev.type || ""; if (t === "session" && ev.id) agentId = ev.id; + if (t === "auto_retry_end") { + if (ev.success === false) { + retryFailed = true; + lastErr = ev.finalError || ev.errorMessage || lastErr || "auto_retry_failed"; + } else { + retryFailed = false; + } + } if (t === "agent_settled") { - succeeded = true; + // PI emits agent_settled even after exhausted retries; that is not a deliverable. + succeeded = !retryFailed; child.stdin.end(); } if (t === "agent_error") { @@ -261,10 +271,18 @@ async function handleRun(body) { // 3. Read generated files and deliverables const patches = []; + const missing = []; for (const rel of allowed) { const abs = resolve(workspace, rel); - if (!existsSync(abs)) continue; + if (!existsSync(abs)) { + missing.push(rel); + continue; + } const content = readFileSync(abs, "utf8"); + if (!String(content).trim()) { + missing.push(rel); + continue; + } patches.push({ rel_path: rel, content }); } @@ -277,6 +295,17 @@ async function handleRun(body) { } catch {} } + if (missing.length > 0) { + return { + ok: false, + agent_id: agentId, + run_id: runId, + fallback_reason: `missing target deliverable ${missing.join(",")}`, + stderr: stderrBuf ? stderrBuf.slice(0, 400) : undefined, + patches, + }; + } + return { ok: true, agent_id: agentId, From 32cf9ad4fcb388f7b3e0baff991bb1e747c7d785 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 19:09:48 +0000 Subject: [PATCH 04/13] docs: claim UI-11 prelim output contract gap in_progress Record the missing-output.md failure, embed/path-mapping tradeoffs, and CURRENT mutex without marking the task done. Co-authored-by: Cursor --- ...6-09-13-document-revision-prelim-output.md | 49 ++++++++++++++++++ .doc/ops/CURRENT.md | 6 ++- ...6-09-13-document-revision-prelim-output.md | 51 +++++++++++++++++++ ...6-09-13-document-revision-prelim-output.md | 39 ++++++++++++++ 4 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 .doc/audit/2026-09-13-document-revision-prelim-output.md create mode 100644 .doc/ops/short-term/2026-09-13-document-revision-prelim-output.md create mode 100644 .doc/remediation/2026-09-13-document-revision-prelim-output.md diff --git a/.doc/audit/2026-09-13-document-revision-prelim-output.md b/.doc/audit/2026-09-13-document-revision-prelim-output.md new file mode 100644 index 00000000..e3e16c74 --- /dev/null +++ b/.doc/audit/2026-09-13-document-revision-prelim-output.md @@ -0,0 +1,49 @@ +--- +title: "UI-11 prelim_design 缺 output.md 导致 TASK_INTERRUPTED" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +severity: high +--- + +# UI-11 prelim_design 缺 output.md 导致 TASK_INTERRUPTED + +## 现象 + +第11轮真实 UI(schema 30,`dev@2a58674`): + +- Prompt 正确 POST **202**,`task_key=prelim_design` +- 旧 intake 未再 DOC_BUSY +- 小队终态 `TASK_INTERRUPTED` / `task_failed` +- 页面:`step step_01_draft missing target deliverable output.md` · `run_1789325979820025769` + +## 根因分析 + +| 层 | 证据 | 问题 | +|---|---|---| +| 模板 | 容器 `/app` 无 `templates/`;工作区 `template.md` 为两段 stub「待确认」 | `GetDocTemplate` 按 cwd 找盘上文件失败后静默回落;writer 只好一次性 `write` 整篇长文 | +| Runner | `tasks/01_draft/execution.log`:`write` 绝对路径 `.../01_draft/output.md` 后 `terminated`;`auto_retry_end success:false`;仍出现 `agent_settled`;文件头 `Outcome: SUCCESS` | `runner.mjs` 把任意 `agent_settled` 当成成功,缺产物仍 `ok:true` | +| 映射 | `internal/agent/piagent.go` 用 `rel_path` 原样做 Patches 键;`workflow.go` 只认 `Patches["output.md"]` 与 `subDir/output.md` | 绝对路径 / `./output.md` / 重复 `tasks/01_draft/output.md` 会被当成缺失 | +| 工作区 | `data/dev/workspaces/bizdev/prelim_design_1789325982486234431` 无任何 `output.md` | 写未落地;不得从截断 write 参数伪造正文 | + +app 日志:`taskrun_status_transition run_id=run_1789325979820025769 error="step step_01_draft missing target deliverable output.md"`。 + +## 严重度 + +`high` + +## 关联整改 + +- Remediation: [../remediation/2026-09-13-document-revision-prelim-output.md](../remediation/2026-09-13-document-revision-prelim-output.md) +- Short-term: [../ops/short-term/2026-09-13-document-revision-prelim-output.md](../ops/short-term/2026-09-13-document-revision-prelim-output.md) +- 分支: `fix/document-revision-prelim-output` + +## 证据 + +- Evidence root(已归档、本轮勿改勿提交):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui11-prelim-design-missing-output-failure/` +- Workspace(只读):`data/dev/workspaces/bizdev/prelim_design_1789325982486234431/` + +## 开放问题 + +- 占槽后真实 UI-11 重跑;本任务保持 `in_progress`,不得标 `done` +- 本轮不重建容器;embed + 契约修复随下次镜像生效 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 7beda461..771d2da8 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -1,11 +1,11 @@ # 当前焦点 **更新**: 2026-09-13 -**production_ready**: no(DOCREV:schema **30**;第十轮 UI-11 路由+lease;不操作容器) +**production_ready**: no(DOCREV:schema **30**;第11轮 UI-11 prelim output 契约;不操作容器) ## 焦点 -1. **DOCREV-RT**: [UI-11 点名优先路由 + PATCH 请求级释租](short-term/2026-09-13-document-revision-second-doc-routing-lease.md)(`fix/document-revision-second-doc-routing-lease`:不改 prompt / 不重跑 E2E / 不操作容器;仍 `in_progress`) +1. **DOCREV-RT**: [prelim_design 缺 output.md 产物契约修复](short-term/2026-09-13-document-revision-prelim-output.md)(`fix/document-revision-prelim-output`:不伪造产物 / 不改 prompt / 不重跑 E2E / 不重建容器;仍 `in_progress`) 2. **CHAT**: [刷新恢复整改已实施](short-term/2026-09-13-chat-history-refresh-remediation.md)(待合 dev + PI E2E 补录) 3. **CI**: `TEST_DATABASE_URL` + 真实 PI E2E 实录;INTENT 发布 blocker 见 conversation-intent short-term @@ -27,6 +27,7 @@ | Cursor-Auto | document-revision-e2e-overlay-selector | fix/document-revision-e2e-overlay-selector | - | document-revision-overlay-selector | in_progress | | Cursor-Auto | document-revision-e2e-overlay-transition | fix/document-revision-e2e-overlay-transition | - | document-revision-overlay-transition | in_progress | | Cursor-Auto | document-revision-second-doc-routing-lease | fix/document-revision-second-doc-routing-lease | - | document-revision-second-doc-routing-lease | in_progress | +| Cursor-Auto | document-revision-prelim-output | fix/document-revision-prelim-output | - | document-revision-prelim-output | in_progress | > 新任务开始前登记。`branch` 必须为 `feature/` 或 `fix/`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。 @@ -71,3 +72,4 @@ | UI-07 纯净阅读取样失败审计 | [../audit/2026-09-13-document-revision-overlay-selector.md](../audit/2026-09-13-document-revision-overlay-selector.md) | | UI-07 transition 中间帧取样审计 | [../audit/2026-09-13-document-revision-overlay-transition.md](../audit/2026-09-13-document-revision-overlay-transition.md) | | UI-11 第二份文档路由与 lease 审计 | [../audit/2026-09-13-document-revision-second-doc-routing-lease.md](../audit/2026-09-13-document-revision-second-doc-routing-lease.md) | +| UI-11 prelim_design 缺 output.md 审计 | [../audit/2026-09-13-document-revision-prelim-output.md](../audit/2026-09-13-document-revision-prelim-output.md) | diff --git a/.doc/ops/short-term/2026-09-13-document-revision-prelim-output.md b/.doc/ops/short-term/2026-09-13-document-revision-prelim-output.md new file mode 100644 index 00000000..b5bef611 --- /dev/null +++ b/.doc/ops/short-term/2026-09-13-document-revision-prelim-output.md @@ -0,0 +1,51 @@ +--- +title: "prelim_design 缺 output.md 产物契约修复" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +owner: Cursor-Auto +branch: fix/document-revision-prelim-output +worktree: "-" +mutex: document-revision-prelim-output +--- + +# prelim_design 缺 output.md 产物契约修复 + +## 目标 + +第11轮 UI-11:202 + `prelim_design` 已正确,但 `step_01_draft` 缺真实 `output.md`。embed 官方模板、归一化 runner 路径、缺产物 fail-closed。不得标 `done`。 + +## 范围 + +- `internal/template` embed + loader +- `internal/agent` 路径映射与目标强制 +- `deploy/pi-agent/runner.mjs` settle 契约 +- `deploy/app/Dockerfile` COPY templates/configs(下次镜像) +- 文档:audit / remediation / CURRENT + +## 非目标 + +- 不伪造 `output.md`;不改 E2E prompt;不放宽缺失校验 +- 不触碰/提交证据目录;不操作/重建容器;本轮不重跑 E2E +- 不改 FEATURE-MATRIX;不合 `dev`;不 push + +## 步骤 + +1. [x] 从 `dev@2a58674` 开 `fix/document-revision-prelim-output` 并认领 mutex +2. [x] 定位:stub 模板 + `agent_settled` 假成功 + 只认精确 `output.md` 键 +3. [x] embed / 映射 / runner fail-closed + 单测 + 门禁 +4. [ ] 占槽后真实 UI 重跑(本轮不做) + +## 验收标准 + +- [ ] 无盘模板时 `prelim_design` 仍为官方「系统初步设计方案」 +- [ ] 绝对/`./`/重复子目录 `rel_path` 能落到目标文件 +- [ ] `ok:true` 但无非空目标 → 失败,不发明正文 +- [ ] intake 工作流单测仍绿 +- [ ] 真实 UI-11 PASS 后才可标 `done` + +## 相关链接 + +- Audit: [../../audit/2026-09-13-document-revision-prelim-output.md](../../audit/2026-09-13-document-revision-prelim-output.md) +- Remediation: [../../remediation/2026-09-13-document-revision-prelim-output.md](../../remediation/2026-09-13-document-revision-prelim-output.md) +- Evidence: [../../test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui11-prelim-design-missing-output-failure](../../test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui11-prelim-design-missing-output-failure) diff --git a/.doc/remediation/2026-09-13-document-revision-prelim-output.md b/.doc/remediation/2026-09-13-document-revision-prelim-output.md new file mode 100644 index 00000000..16f4a586 --- /dev/null +++ b/.doc/remediation/2026-09-13-document-revision-prelim-output.md @@ -0,0 +1,39 @@ +--- +title: "prelim_design 真实产物契约:embed 模板 + 路径映射 + 缺产物 fail-closed" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +--- + +# prelim_design 真实产物契约 + +## 背景 + +链到 [../audit/2026-09-13-document-revision-prelim-output.md](../audit/2026-09-13-document-revision-prelim-output.md)。 + +## 目标 + +通用、fail-safe 的产物契约:成功步必须有真实、非空的目标文件。不得伪造 `output.md`、不得改 E2E prompt、不得放宽缺失校验。intake 与其他 `task_key` 不回归。 + +## 分波次计划 + +| 波次 | 内容 | 验收 | +|------|------|------| +| Wave 0 | `//go:embed` 官方 `templates/docs/*.md`;`GetDocTemplate` 先读 embed | 无盘上 `templates/` 时 `prelim_design` / `intake` 仍是正式模板,不是两段 stub | +| Wave 0 | Runner `rel_path` 归一化到目标名;缺产物则 `Success=false` | 绝对路径 / `./` / 重复子目录仍能落盘;空 patches 仍失败 | +| Wave 0 | `runner.mjs`:`auto_retry_end` 失败或目标文件不存在不得 `ok:true` | 与现场 `agent_settled` 假成功对齐 | + +## 取舍 + +- 不从 `execution.log` 截断的 `write` 参数拼出正文。 +- 不改 UI-11 prompt。Dockerfile 补 `COPY templates/configs` 仅供下次镜像,本轮不 rebuild。 + +## 验收标准 + +- [x] embed + 路径映射 + runner fail-closed + 单测(门禁见提交说明) +- [ ] 占槽后真实 UI-11 PASS 才可将本任务标 `done` + +## 相关链接 + +- Audit: [../audit/2026-09-13-document-revision-prelim-output.md](../audit/2026-09-13-document-revision-prelim-output.md) +- Short-term: [../ops/short-term/2026-09-13-document-revision-prelim-output.md](../ops/short-term/2026-09-13-document-revision-prelim-output.md) From 98619c7b56f0941dd30da757812edae6fc32c8f4 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 19:16:46 +0000 Subject: [PATCH 05/13] feat(agent): classify settled-failure as bounded retryable Treat process-0 plus terminated/Connection error/auto_retry_end as a provider transient. Permanent missing deliverables stay fail-closed and are never reconstructed from truncated write arguments. Co-authored-by: Cursor --- internal/agent/backend.go | 3 + internal/agent/backend_test.go | 77 +++++++++++++ internal/agent/piagent.go | 47 ++++++-- internal/agent/step_retry.go | 186 ++++++++++++++++++++++++++++++ internal/agent/step_retry_test.go | 143 +++++++++++++++++++++++ 5 files changed, 446 insertions(+), 10 deletions(-) create mode 100644 internal/agent/step_retry.go create mode 100644 internal/agent/step_retry_test.go diff --git a/internal/agent/backend.go b/internal/agent/backend.go index 34b6e406..d18cbbbb 100644 --- a/internal/agent/backend.go +++ b/internal/agent/backend.go @@ -20,6 +20,9 @@ type Result struct { Patches map[string][]byte `json:"patches"` Logs string `json:"logs"` ErrorReason string `json:"error_reason"` + // Retryable marks a provider-transient failure that may receive a + // fresh step invocation. Permanent missing deliverables stay false. + Retryable bool `json:"retryable,omitempty"` } type Backend interface { diff --git a/internal/agent/backend_test.go b/internal/agent/backend_test.go index cb082435..5d9a2cb3 100644 --- a/internal/agent/backend_test.go +++ b/internal/agent/backend_test.go @@ -186,3 +186,80 @@ func TestPiAgentRemoteRunner_PathAliasesAndMissing(t *testing.T) { run("dot", "# from dot rel_path\n", true) run("missing", "", false) } + +func TestPiAgentRemoteRunner_PropagatesRetryable(t *testing.T) { + dir := t.TempDir() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": false, + "retryable": true, + "fallback_reason": "Connection error.", + "patches": []any{}, + }) + })) + defer ts.Close() + res, err := agent.NewBackend("pi-agent", &config.Config{ + AgentBackend: "pi-agent", + PiAgentRunnerURL: ts.URL, + PiAgentModel: "MiniMax-M3", + PiAgentProvider: "minimax", + AgentTimeout: 5 * time.Second, + }).Execute(context.Background(), agent.Request{ + WorkDir: dir, + Prompt: "draft", + Targets: []string{"output.md"}, + }) + if err != nil { + t.Fatal(err) + } + if res.Success || !res.Retryable { + t.Fatalf("want retryable failure, success=%v retryable=%v reason=%s", res.Success, res.Retryable, res.ErrorReason) + } + if _, serr := os.Stat(filepath.Join(dir, "output.md")); serr == nil { + t.Fatal("must not invent output.md") + } +} + +func TestPiAgentRemoteRunner_OldSettleLieIsRetryable(t *testing.T) { + dir := t.TempDir() + log := "Outcome: SUCCESS\n" + + `{"type":"message_end","message":{"stopReason":"error","errorMessage":"terminated"}}` + "\n" + + `{"type":"auto_retry_end","success":false,"attempt":3,"finalError":"Connection error."}` + "\n" + + `{"type":"agent_settled"}` + "\n" + if err := os.WriteFile(filepath.Join(dir, "execution.log"), []byte(log), 0o644); err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "patches": []map[string]string{{ + "rel_path": "execution.log", + "content": "Outcome: SUCCESS\n", + }}, + }) + })) + defer ts.Close() + res, err := agent.NewBackend("pi-agent", &config.Config{ + AgentBackend: "pi-agent", + PiAgentRunnerURL: ts.URL, + PiAgentModel: "MiniMax-M3", + PiAgentProvider: "minimax", + AgentTimeout: 5 * time.Second, + }).Execute(context.Background(), agent.Request{ + WorkDir: dir, + Prompt: "draft", + Targets: []string{"output.md"}, + }) + if err != nil { + t.Fatal(err) + } + if res.Success { + t.Fatal("process-0 settle without output.md must not succeed") + } + if !res.Retryable { + t.Fatalf("old runner lie + provider trace must be retryable, reason=%s", res.ErrorReason) + } + if _, serr := os.Stat(filepath.Join(dir, "output.md")); serr == nil { + t.Fatal("must not reconstruct output.md from truncated write args") + } +} diff --git a/internal/agent/piagent.go b/internal/agent/piagent.go index 389a4856..e0fd8b2d 100644 --- a/internal/agent/piagent.go +++ b/internal/agent/piagent.go @@ -74,16 +74,19 @@ func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, err err := cmd.Run() if err != nil { - return &Result{ + res := &Result{ Success: false, ErrorReason: fmt.Sprintf("pi execution error: %v, stderr: %s", err, stderr.String()), - }, nil + } + res.Retryable = AssessStepFailure(StepFailureSignals{Result: res, WorkDir: workDir}).Class == FailureTransient + return res, nil } type bridgeResponse struct { OK bool `json:"ok"` FallbackReason string `json:"fallback_reason"` Stderr string `json:"stderr"` + Retryable bool `json:"retryable"` } var bResp bridgeResponse lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") @@ -99,21 +102,32 @@ func (p *PiAgentBackend) Execute(ctx context.Context, req Request) (*Result, err if errMsg == "" { errMsg = "pi bridge reported task failed" } - return &Result{ + res := &Result{ Success: false, ErrorReason: errMsg, Logs: stdout.String(), - }, nil + Retryable: bResp.Retryable, + } + if !res.Retryable { + res.Retryable = AssessStepFailure(StepFailureSignals{Result: res, WorkDir: workDir}).Class == FailureTransient + } + return res, nil } patches, err := MaterializeTargets(workDir, req.Targets, nil) if err != nil { - return &Result{ + res := &Result{ Success: false, ErrorReason: err.Error(), Patches: patches, Logs: stdout.String(), - }, nil + } + res.Retryable = AssessStepFailure(StepFailureSignals{ + Result: res, + WorkDir: workDir, + TargetMissing: true, + }).Class == FailureTransient + return res, nil } return &Result{ @@ -150,6 +164,7 @@ func (p *PiAgentBackend) executeRemote(ctx context.Context, runnerURL string, re return &Result{ Success: false, ErrorReason: fmt.Sprintf("pi remote runner call failed: %v", err), + Retryable: true, }, nil } defer resp.Body.Close() @@ -158,6 +173,7 @@ func (p *PiAgentBackend) executeRemote(ctx context.Context, runnerURL string, re OK bool `json:"ok"` FallbackReason string `json:"fallback_reason"` Stderr string `json:"stderr"` + Retryable bool `json:"retryable"` Patches []struct { RelPath string `json:"rel_path"` Content string `json:"content"` @@ -178,10 +194,15 @@ func (p *PiAgentBackend) executeRemote(ctx context.Context, runnerURL string, re if reason == "" { reason = "remote pi-agent reported task failure" } - return &Result{ + res := &Result{ Success: false, ErrorReason: reason, - }, nil + Retryable: remoteResp.Retryable, + } + if !res.Retryable { + res.Retryable = AssessStepFailure(StepFailureSignals{Result: res, WorkDir: workDir}).Class == FailureTransient + } + return res, nil } incoming := make(map[string][]byte, len(remoteResp.Patches)) @@ -190,11 +211,17 @@ func (p *PiAgentBackend) executeRemote(ctx context.Context, runnerURL string, re } patches, matErr := MaterializeTargets(workDir, req.Targets, incoming) if matErr != nil { - return &Result{ + res := &Result{ Success: false, ErrorReason: matErr.Error(), Patches: patches, - }, nil + } + res.Retryable = remoteResp.Retryable || AssessStepFailure(StepFailureSignals{ + Result: res, + WorkDir: workDir, + TargetMissing: true, + }).Class == FailureTransient + return res, nil } return &Result{ diff --git a/internal/agent/step_retry.go b/internal/agent/step_retry.go new file mode 100644 index 00000000..ce46e491 --- /dev/null +++ b/internal/agent/step_retry.go @@ -0,0 +1,186 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "unicode" +) + +// MaxTransientStepInvocations is the product-layer bound: the first +// invocation plus one fresh re-invocation. SDK in-process auto_retry is +// separate and must not be confused with this. +const MaxTransientStepInvocations = 2 + +type FailureClass int + +const ( + FailureNone FailureClass = iota + FailureTransient + FailurePermanent +) + +func (c FailureClass) String() string { + switch c { + case FailureTransient: + return "transient" + case FailurePermanent: + return "permanent" + default: + return "none" + } +} + +type TraceSignals struct { + ProviderTransient bool + Settled bool + RetryExhausted bool + HasUsableDeliverable bool // always false: write() args in traces are not deliverables +} + +type StepFailureSignals struct { + Err error + Result *Result + WorkDir string + ExtraText string + TargetMissing bool +} + +type FailureAssessment struct { + Class FailureClass + Reason string +} + +func ScanTraceText(text string) TraceSignals { + var out TraceSignals + if text == "" { + return out + } + // Line scan only. Never parse write.arguments.content into a body. + for _, line := range strings.Split(text, "\n") { + if line == "" { + continue + } + lower := strings.ToLower(line) + if strings.Contains(lower, `"type":"agent_settled"`) || strings.Contains(lower, `"type": "agent_settled"`) { + out.Settled = true + } + if strings.Contains(lower, `"type":"auto_retry_end"`) && strings.Contains(lower, `"success":false`) { + out.ProviderTransient = true + out.RetryExhausted = true + } + if strings.Contains(lower, `"type":"auto_retry_start"`) { + out.ProviderTransient = true + } + if strings.Contains(lower, `"stopreason":"error"`) { + out.ProviderTransient = true + } + if strings.Contains(lower, "connection error") { + out.ProviderTransient = true + } + if strings.Contains(lower, `"errormessage":"terminated"`) || strings.Contains(lower, `"errormessage": "terminated"`) { + out.ProviderTransient = true + } + } + return out +} + +func ReadWorkDirTraces(workDir string) string { + if strings.TrimSpace(workDir) == "" { + return "" + } + p := filepath.Join(workDir, "execution.log") + b, err := os.ReadFile(p) + if err != nil { + return "" + } + const capBytes = 512 << 10 + if len(b) > capBytes { + b = b[len(b)-capBytes:] + } + return string(b) +} + +func AssessStepFailure(sig StepFailureSignals) FailureAssessment { + reason := combinedFailureText(sig) + lower := strings.ToLower(reason) + trace := ScanTraceText(sig.ExtraText + "\n" + ReadWorkDirTraces(sig.WorkDir)) + missing := sig.TargetMissing || strings.Contains(lower, "missing target deliverable") + + if isPermanentReason(lower) && !trace.ProviderTransient { + return FailureAssessment{Class: FailurePermanent, Reason: "permanent: " + compactReason(reason)} + } + if isPermanentReason(lower) && !missing { + return FailureAssessment{Class: FailurePermanent, Reason: "permanent: " + compactReason(reason)} + } + + if sig.Result != nil && sig.Result.Retryable { + return FailureAssessment{Class: FailureTransient, Reason: "runner-retryable: " + compactReason(reason)} + } + if trace.ProviderTransient { + kind := "provider-transient" + if missing && trace.Settled { + kind = "settled-failure-missing-target" + } + return FailureAssessment{Class: FailureTransient, Reason: kind + ": " + compactReason(reason)} + } + if hasTransientKeyword(lower) { + return FailureAssessment{Class: FailureTransient, Reason: "provider-transient: " + compactReason(reason)} + } + if missing { + return FailureAssessment{Class: FailurePermanent, Reason: "permanent-missing: " + compactReason(reason)} + } + return FailureAssessment{Class: FailurePermanent, Reason: "fail-closed: " + compactReason(reason)} +} + +func combinedFailureText(sig StepFailureSignals) string { + var parts []string + if sig.Err != nil { + parts = append(parts, sig.Err.Error()) + } + if sig.Result != nil { + if sig.Result.ErrorReason != "" { + parts = append(parts, sig.Result.ErrorReason) + } + if sig.Result.Logs != "" { + parts = append(parts, sig.Result.Logs) + } + } + return strings.Join(parts, " ") +} + +func isPermanentReason(lower string) bool { + return strings.Contains(lower, "prompt_empty") || + strings.Contains(lower, "unauthorized") || + strings.Contains(lower, "治理强制终止") || + strings.Contains(lower, "治理拦截") +} + +func hasTransientKeyword(lower string) bool { + return strings.Contains(lower, "connection error") || + strings.Contains(lower, "terminated") || + strings.Contains(lower, "auto_retry") || + strings.Contains(lower, "econnreset") || + strings.Contains(lower, "socket hang up") || + strings.Contains(lower, "pi remote runner call failed") +} + +func compactReason(s string) string { + s = strings.Join(strings.FieldsFunc(s, func(r rune) bool { + return r == '\n' || r == '\r' + }), " ") + s = strings.TrimSpace(s) + if s == "" { + return "unknown" + } + // Drop accidental write-argument bodies if a caller stuffed logs here. + if len(s) > 240 { + s = s[:240] + } + return strings.Map(func(r rune) rune { + if unicode.IsControl(r) && r != '\t' { + return -1 + } + return r + }, s) +} diff --git a/internal/agent/step_retry_test.go b/internal/agent/step_retry_test.go new file mode 100644 index 00000000..f1a0c1f6 --- /dev/null +++ b/internal/agent/step_retry_test.go @@ -0,0 +1,143 @@ +package agent + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// Synthetic UI-11 timeline — event types only. Do not include write.content. +const ui11SettledFailureTrace = ` +{"type":"message_end","message":{"stopReason":"error","errorMessage":"terminated"}} +{"type":"auto_retry_start","attempt":1,"maxAttempts":3,"errorMessage":"terminated"} +{"type":"message_end","message":{"stopReason":"error","errorMessage":"Connection error."}} +{"type":"auto_retry_start","attempt":2,"maxAttempts":3,"errorMessage":"Connection error."} +{"type":"auto_retry_start","attempt":3,"maxAttempts":3,"errorMessage":"Connection error."} +{"type":"auto_retry_end","success":false,"attempt":3,"finalError":"Connection error."} +{"type":"agent_settled"} +` + +func TestScanTraceText_UI11ProviderTransient(t *testing.T) { + got := ScanTraceText(ui11SettledFailureTrace) + if !got.ProviderTransient { + t.Fatal("terminated + Connection error + auto_retry_end success=false must be provider-transient") + } + if !got.Settled { + t.Fatal("agent_settled must be recorded") + } + if !got.RetryExhausted { + t.Fatal("auto_retry_end success=false must mark SDK retry exhausted") + } +} + +func TestScanTraceText_IgnoresWriteArguments(t *testing.T) { + // A truncated write toolCall must never become a deliverable signal. + line := `{"type":"message_end","message":{"content":[{"type":"toolCall","name":"write","arguments":{"path":"/app/data/workspaces/x/tasks/01_draft/output.md","content":"# 伪造正文不应被采用\n"}}]}}` + got := ScanTraceText(line) + if got.HasUsableDeliverable { + t.Fatal("write arguments in a trace line are not a usable deliverable") + } + if got.ProviderTransient { + t.Fatal("a write toolCall alone is not a provider transient") + } +} + +func TestAssessStepFailure_TransientVsPermanent(t *testing.T) { + cases := []struct { + name string + sig StepFailureSignals + want FailureClass + }{ + { + name: "runner retryable flag", + sig: StepFailureSignals{Result: &Result{Success: false, ErrorReason: "Connection error.", Retryable: true}}, + want: FailureTransient, + }, + { + name: "connection error text", + sig: StepFailureSignals{Result: &Result{Success: false, ErrorReason: "Connection error."}}, + want: FailureTransient, + }, + { + name: "terminated text", + sig: StepFailureSignals{Result: &Result{Success: false, ErrorReason: "terminated"}}, + want: FailureTransient, + }, + { + name: "missing after UI-11 settled failure", + sig: StepFailureSignals{ + Result: &Result{Success: false, ErrorReason: "missing target deliverable output.md"}, + ExtraText: ui11SettledFailureTrace, + TargetMissing: true, + }, + want: FailureTransient, + }, + { + name: "missing without provider signal is permanent", + sig: StepFailureSignals{ + Result: &Result{Success: false, ErrorReason: "missing target deliverable output.md"}, + ExtraText: `{"type":"agent_settled"}`, + TargetMissing: true, + }, + want: FailurePermanent, + }, + { + name: "prompt_empty is permanent", + sig: StepFailureSignals{Result: &Result{Success: false, ErrorReason: "prompt_empty"}}, + want: FailurePermanent, + }, + { + name: "unauthorized is permanent", + sig: StepFailureSignals{Result: &Result{Success: false, ErrorReason: "unauthorized"}}, + want: FailurePermanent, + }, + { + name: "governance abort is permanent", + sig: StepFailureSignals{Err: errors.New("治理强制终止: 原地打转")}, + want: FailurePermanent, + }, + { + name: "remote runner unreachable is transient", + sig: StepFailureSignals{Result: &Result{Success: false, ErrorReason: "pi remote runner call failed: connection refused"}}, + want: FailureTransient, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := AssessStepFailure(tc.sig) + if got.Class != tc.want { + t.Fatalf("class=%s reason=%s want=%s", got.Class, got.Reason, tc.want) + } + }) + } +} + +func TestAssessStepFailure_ReadsWorkDirExecutionLog(t *testing.T) { + dir := t.TempDir() + log := "=== PI-AGENT TASK EXECUTION LOG ===\nOutcome: SUCCESS\nError: None\n" + ui11SettledFailureTrace + if err := os.WriteFile(filepath.Join(dir, "execution.log"), []byte(log), 0o644); err != nil { + t.Fatal(err) + } + got := AssessStepFailure(StepFailureSignals{ + WorkDir: dir, + TargetMissing: true, + Result: &Result{Success: true, ErrorReason: "missing target deliverable output.md"}, + }) + if got.Class != FailureTransient { + t.Fatalf("old runner Outcome:SUCCESS + settled failure must be transient, got %s %s", got.Class, got.Reason) + } + if strings.Contains(got.Reason, "伪造") || strings.Contains(got.Reason, "系统初步设计") { + t.Fatalf("assessment must not carry write-argument body: %s", got.Reason) + } +} + +func TestMaxTransientStepInvocations_IsBounded(t *testing.T) { + if MaxTransientStepInvocations < 2 { + t.Fatal("need at least one fresh re-invocation after the first") + } + if MaxTransientStepInvocations > 3 { + t.Fatalf("bound too loose: %d would retry permanent-looking gaps too many times", MaxTransientStepInvocations) + } +} From ce89939cd80d6577ae41167942e48d11812e291d Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 19:17:09 +0000 Subject: [PATCH 06/13] feat(service): retry transient step failures with a fresh invocation runOneStep re-executes the backend at most once when the first attempt is a settled failure or missing target after provider errors, records each attempt honestly, and fail-closes after the bound. Co-authored-by: Cursor --- internal/squad/step_retry_test.go | 181 ++++++++++++++++++++++++++++++ internal/squad/workflow.go | 138 ++++++++++++++++------- 2 files changed, 276 insertions(+), 43 deletions(-) create mode 100644 internal/squad/step_retry_test.go diff --git a/internal/squad/step_retry_test.go b/internal/squad/step_retry_test.go new file mode 100644 index 00000000..c88aae97 --- /dev/null +++ b/internal/squad/step_retry_test.go @@ -0,0 +1,181 @@ +package squad_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "chat-agent-system/internal/agent" + "chat-agent-system/internal/squad" +) + +type countingBackend struct { + mu sync.Mutex + calls int + mode string + failText string +} + +func (b *countingBackend) Name() string { return "counting" } + +func (b *countingBackend) Execute(ctx context.Context, req agent.Request) (*agent.Result, error) { + b.mu.Lock() + b.calls++ + n := b.calls + mode := b.mode + failText := b.failText + b.mu.Unlock() + + switch mode { + case "transient-then-ok": + if n == 1 { + _ = os.WriteFile(filepath.Join(req.WorkDir, "execution.log"), []byte( + `{"type":"auto_retry_end","success":false,"finalError":"Connection error."}`+"\n"+ + `{"type":"agent_settled"}`+"\n", + ), 0o644) + return &agent.Result{Success: false, ErrorReason: "Connection error.", Retryable: true}, nil + } + body := []byte("# recovered draft\n\nfresh step invocation\n") + _ = os.WriteFile(filepath.Join(req.WorkDir, "output.md"), body, 0o644) + return &agent.Result{Success: true, Patches: map[string][]byte{"output.md": body}}, nil + + case "old-runner-lie-then-ok": + if n == 1 { + _ = os.WriteFile(filepath.Join(req.WorkDir, "execution.log"), []byte( + "Outcome: SUCCESS\nError: None\n"+ + `{"type":"message_end","message":{"stopReason":"error","errorMessage":"terminated"}}`+"\n"+ + `{"type":"auto_retry_end","success":false,"attempt":3,"finalError":"Connection error."}`+"\n"+ + `{"type":"agent_settled"}`+"\n", + ), 0o644) + // Old runner: process 0 + settled + empty patches. + return &agent.Result{Success: true, Patches: map[string][]byte{ + "execution.log": []byte("Outcome: SUCCESS\n"), + }}, nil + } + body := []byte("# recovered after old-runner lie\n") + _ = os.WriteFile(filepath.Join(req.WorkDir, "output.md"), body, 0o644) + return &agent.Result{Success: true, Patches: map[string][]byte{"output.md": body}}, nil + + case "permanent-missing": + return &agent.Result{Success: false, ErrorReason: "missing target deliverable output.md"}, nil + + case "always-transient": + return &agent.Result{Success: false, ErrorReason: failText, Retryable: true}, nil + + default: + return &agent.Result{Success: false, ErrorReason: "unknown mode"}, nil + } +} + +func prepareSingleWriterWS(t *testing.T) (string, *squad.WorkflowDriver) { + t.Helper() + tmpDir := t.TempDir() + engine := squad.NewWorkspaceEngine() + ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ + WorkDir: tmpDir, + TaskKey: "prelim_design", + StageTitle: "系统初步设计方案", + ProjectName: "retry-audit", + Backend: "pi-agent", + WorkflowSteps: []squad.StepSpec{ + { + ID: "step_01_draft", + Role: "writer", + Title: "撰写初稿", + SubWorkspace: "tasks/01_draft", + Mode: "execute", + }, + }, + }) + if err != nil { + t.Fatalf("PrepareWorkspace: %v", err) + } + return ws.WorkDir, squad.NewWorkflowDriver(engine) +} + +func TestWorkflowDriver_RetriesTransientSettledFailure(t *testing.T) { + wsDir, driver := prepareSingleWriterWS(t) + backend := &countingBackend{mode: "transient-then-ok"} + res, err := driver.Execute(context.Background(), wsDir, backend, squad.ExecuteOptions{Timeout: 5 * time.Second}) + if err != nil { + t.Fatalf("transient then recover must succeed: %v", err) + } + if !res.Success { + t.Fatalf("want success, got %s", res.ErrorReason) + } + if backend.calls != 2 { + t.Fatalf("want one fresh re-invocation, calls=%d", backend.calls) + } + body, err := os.ReadFile(filepath.Join(wsDir, "tasks", "01_draft", "output.md")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), "fresh step invocation") { + t.Fatalf("recovered body missing: %s", body) + } + entries := squad.LoadTrace(wsDir) + if len(entries) < 2 { + t.Fatalf("each invocation must be traced, got %d", len(entries)) + } + if entries[0].Success { + t.Fatal("first settled-failure invocation must not be recorded as success") + } + if !entries[1].Success { + t.Fatal("recovered invocation must be success") + } +} + +func TestWorkflowDriver_RetriesOldRunnerLieWhenTraceShowsSettledFailure(t *testing.T) { + wsDir, driver := prepareSingleWriterWS(t) + backend := &countingBackend{mode: "old-runner-lie-then-ok"} + res, err := driver.Execute(context.Background(), wsDir, backend, squad.ExecuteOptions{Timeout: 5 * time.Second}) + if err != nil { + t.Fatalf("old-runner lie + transient trace must retry: %v", err) + } + if !res.Success { + t.Fatalf("want success after fresh invocation, got %s", res.ErrorReason) + } + if backend.calls != 2 { + t.Fatalf("want fresh invocation, calls=%d", backend.calls) + } + if _, err := os.Stat(filepath.Join(wsDir, "tasks", "01_draft", "output.md")); err != nil { + t.Fatal("must not invent output from truncated write args; recovered file should come from second invocation") + } +} + +func TestWorkflowDriver_DoesNotRetryPermanentMissing(t *testing.T) { + wsDir, driver := prepareSingleWriterWS(t) + backend := &countingBackend{mode: "permanent-missing"} + res, err := driver.Execute(context.Background(), wsDir, backend, squad.ExecuteOptions{Timeout: 5 * time.Second}) + if err == nil || res.Success { + t.Fatal("permanent missing must fail-closed") + } + if backend.calls != 1 { + t.Fatalf("permanent missing must not be retried, calls=%d", backend.calls) + } + if !strings.Contains(err.Error(), "missing target deliverable output.md") { + t.Fatalf("reason: %v", err) + } + if _, serr := os.Stat(filepath.Join(wsDir, "tasks", "01_draft", "output.md")); serr == nil { + t.Fatal("must not forge output.md") + } +} + +func TestWorkflowDriver_BoundedTransientRetryThenFailClosed(t *testing.T) { + wsDir, driver := prepareSingleWriterWS(t) + backend := &countingBackend{mode: "always-transient", failText: "Connection error."} + res, err := driver.Execute(context.Background(), wsDir, backend, squad.ExecuteOptions{Timeout: 5 * time.Second}) + if err == nil || res.Success { + t.Fatal("exhausted transient retries must fail-closed") + } + if backend.calls != agent.MaxTransientStepInvocations { + t.Fatalf("calls=%d want bound %d", backend.calls, agent.MaxTransientStepInvocations) + } + if _, serr := os.Stat(filepath.Join(wsDir, "tasks", "01_draft", "output.md")); serr == nil { + t.Fatal("must not forge output.md after exhausted retries") + } +} diff --git a/internal/squad/workflow.go b/internal/squad/workflow.go index 91f6170a..c5e2e9f9 100644 --- a/internal/squad/workflow.go +++ b/internal/squad/workflow.go @@ -503,52 +503,101 @@ func (d *WorkflowDriver) runOneStep( Timeout: stepTimeout, } - res, err := backend.Execute(ctx, req) - duration := time.Since(stepStart).Milliseconds() + var ( + res *agent.Result + execErr error + matErr error + duration int64 + ok bool + ) + for attempt := 1; attempt <= agent.MaxTransientStepInvocations; attempt++ { + if attempt > 1 && streamer != nil { + streamer.EmitPhase( + "step_retrying", + fmt.Sprintf("%s遇瞬时失败,发起全新步骤调用(%d/%d)", roleTitle, attempt, agent.MaxTransientStepInvocations), + step.Role, 0, 0, 0, + ) + } - // 治理:轨迹入账(prompt/output 指纹 + 与上轮相似度)与预算入账 - newContent := "" - if res != nil { - if b, ok := res.Patches[deliverableName]; ok { - newContent = string(b) + res, execErr = backend.Execute(ctx, req) + duration = time.Since(stepStart).Milliseconds() + matErr = nil + incoming := map[string][]byte{} + if res != nil { + incoming = res.Patches } - } - if newContent == "" { - if b, rErr := os.ReadFile(prevOutPath); rErr == nil { - newContent = string(b) + + if execErr == nil && res != nil && res.Success { + if _, matErr = agent.MaterializeTargets(subDir, []string{deliverableName}, incoming); matErr != nil { + res.Success = false + res.ErrorReason = matErr.Error() + } } - } - sim := OutputSimilarity(prevContent, newContent) - if mu != nil { - mu.Lock() - } - _ = AppendTrace(wsDir, TraceEntry{ - Seq: len(histEntries) + 1, - StepID: step.ID, - Role: step.Role, - Timestamp: time.Now(), - PromptDigest: digestString(string(taskData)), - OutputDigest: digestString(newContent), - SimilarityToPrev: sim, - DurationMs: duration, - Success: err == nil && res != nil && res.Success, - }) - if mu != nil { - mu.Unlock() - } - if ledger != nil { + + newContent := "" + if res != nil { + if b, has := res.Patches[deliverableName]; has { + newContent = string(b) + } + } + if newContent == "" { + if b, rErr := os.ReadFile(prevOutPath); rErr == nil { + newContent = string(b) + } + } + ok = execErr == nil && res != nil && res.Success && matErr == nil + sim := OutputSimilarity(prevContent, newContent) if mu != nil { mu.Lock() } - ledger.RecordAgentCall() + traceSeq := len(LoadTrace(wsDir)) + 1 + _ = AppendTrace(wsDir, TraceEntry{ + Seq: traceSeq, + StepID: step.ID, + Role: step.Role, + Timestamp: time.Now(), + PromptDigest: digestString(string(taskData)), + OutputDigest: digestString(newContent), + SimilarityToPrev: sim, + DurationMs: duration, + Success: ok, + }) if mu != nil { mu.Unlock() } + if ledger != nil { + if mu != nil { + mu.Lock() + } + ledger.RecordAgentCall() + if mu != nil { + mu.Unlock() + } + } + + if ok { + break + } + if ctx.Err() != nil { + if execErr == nil { + execErr = ctx.Err() + } + break + } + assess := agent.AssessStepFailure(agent.StepFailureSignals{ + Err: execErr, + Result: res, + WorkDir: subDir, + TargetMissing: matErr != nil || (res != nil && strings.Contains(res.ErrorReason, "missing target deliverable")), + }) + if assess.Class != agent.FailureTransient || attempt >= agent.MaxTransientStepInvocations { + break + } } - if err != nil { + if execErr != nil { if streamer != nil { - streamer.EmitPhase("step_failed", fmt.Sprintf("%s执行异常:%s", roleTitle, err.Error()), step.Role, 0, 0, 0) + streamer.EmitPhase("step_failed", fmt.Sprintf("%s执行异常:%s", roleTitle, execErr.Error()), step.Role, 0, 0, 0) } if notify != nil { notify(StepEvent{ @@ -558,10 +607,10 @@ func (d *WorkflowDriver) runOneStep( Mode: step.Mode, Status: "failed", DurationMs: duration, - Error: err.Error(), + Error: execErr.Error(), }) } - return fmt.Errorf("step %s (%s) backend execute: %w", step.ID, step.Role, err) + return fmt.Errorf("step %s (%s) backend execute: %w", step.ID, step.Role, execErr) } if res != nil && !res.Success { @@ -583,14 +632,17 @@ func (d *WorkflowDriver) runOneStep( return fmt.Errorf("step %s failed: %s", step.ID, errMsg) } - // 2. 确认 deliverable 文件落盘(路径别名归一化;缺产物不得编造) - incoming := map[string][]byte{} - if res != nil { - incoming = res.Patches - } - if _, matErr := agent.MaterializeTargets(subDir, []string{deliverableName}, incoming); matErr != nil { - return fmt.Errorf("step %s %w", step.ID, matErr) + if !ok { + reason := "missing target deliverable " + deliverableName + if res != nil && res.ErrorReason != "" { + reason = res.ErrorReason + } + if matErr != nil { + reason = matErr.Error() + } + return fmt.Errorf("step %s %s", step.ID, reason) } + targetPath := filepath.Join(subDir, deliverableName) // 3. 检查是否有 fields.json 并合并入 blackboard.json From 11bdba00526648285cb4c899b2ea03df2533089d Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 19:17:09 +0000 Subject: [PATCH 07/13] fix(pi-agent): treat exhausted SDK retry plus settle as failure agent_settled after terminated or auto_retry_end success=false is not a deliverable. Return retryable for provider transients and keep clean missing targets permanent. Co-authored-by: Cursor --- deploy/pi-agent/runner.mjs | 41 ++++----- deploy/pi-agent/settle-policy.mjs | 116 +++++++++++++++++++++++++ deploy/pi-agent/settle-policy.test.mjs | 51 +++++++++++ 3 files changed, 188 insertions(+), 20 deletions(-) create mode 100644 deploy/pi-agent/settle-policy.mjs create mode 100644 deploy/pi-agent/settle-policy.test.mjs diff --git a/deploy/pi-agent/runner.mjs b/deploy/pi-agent/runner.mjs index 845d28b7..e163dd7a 100755 --- a/deploy/pi-agent/runner.mjs +++ b/deploy/pi-agent/runner.mjs @@ -10,6 +10,7 @@ import { spawn } from "node:child_process"; import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { resolve, relative, isAbsolute } from "node:path"; import { TavilyKeyPool, searchTavily, formatTavilyResults } from "./extensions/tavily-search.mjs"; +import { applyAgentEvent, classifyRunOutcome, newSettleState } from "./settle-policy.mjs"; const PORT = parseInt(process.env.PORT || process.env.PI_AGENT_RUNNER_PORT || "18766", 10); const DATA_DIR = process.env.DATA_DIR || "/app/data/workspaces"; @@ -159,8 +160,7 @@ async function handleRun(body) { let agentId = ""; let runId = ""; let lastErr = ""; - let succeeded = false; - let retryFailed = false; + const settle = newSettleState(); console.log(`[runner] Starting pi run in workspace: ${workspace}, model: ${model}`); @@ -202,22 +202,10 @@ async function handleRun(body) { const t = ev.type || ""; if (t === "session" && ev.id) agentId = ev.id; - if (t === "auto_retry_end") { - if (ev.success === false) { - retryFailed = true; - lastErr = ev.finalError || ev.errorMessage || lastErr || "auto_retry_failed"; - } else { - retryFailed = false; - } - } + applyAgentEvent(settle, ev); if (t === "agent_settled") { - // PI emits agent_settled even after exhausted retries; that is not a deliverable. - succeeded = !retryFailed; child.stdin.end(); } - if (t === "agent_error") { - lastErr = ev.error?.message || ev.message || "agent_error"; - } } }); @@ -248,8 +236,8 @@ async function handleRun(body) { `Timestamp: ${new Date().toISOString()}`, `Workspace: ${workspace}`, `Model: ${model} (${provider})`, - `Outcome: ${succeeded ? "SUCCESS" : "FAILED"}`, - `Error: ${lastErr || "None"}`, + `Outcome: ${settle.succeeded ? "SUCCESS" : "FAILED"}`, + `Error: ${settle.lastErr || lastErr || "None"}`, `\n--- RAW OUTPUT & EVENTS ---\n${fullStdout}`, `\n--- STDERR ---\n${stderrBuf}`, ].join("\n"); @@ -258,12 +246,19 @@ async function handleRun(body) { console.warn("[runner] Could not write execution.log:", logErr.message); } - if (!succeeded) { + lastErr = settle.lastErr || lastErr; + if (!settle.succeeded) { + const classified = classifyRunOutcome({ + ...settle, + lastErr, + missing: [], + }); return { ok: false, agent_id: agentId, run_id: runId, - fallback_reason: lastErr || "agent_not_settled", + retryable: classified.retryable, + fallback_reason: classified.fallback_reason, stderr: stderrBuf ? stderrBuf.slice(0, 400) : undefined, patches: [], }; @@ -296,11 +291,17 @@ async function handleRun(body) { } if (missing.length > 0) { + const classified = classifyRunOutcome({ + ...settle, + lastErr, + missing, + }); return { ok: false, agent_id: agentId, run_id: runId, - fallback_reason: `missing target deliverable ${missing.join(",")}`, + retryable: classified.retryable, + fallback_reason: classified.fallback_reason, stderr: stderrBuf ? stderrBuf.slice(0, 400) : undefined, patches, }; diff --git a/deploy/pi-agent/settle-policy.mjs b/deploy/pi-agent/settle-policy.mjs new file mode 100644 index 00000000..9a88aca3 --- /dev/null +++ b/deploy/pi-agent/settle-policy.mjs @@ -0,0 +1,116 @@ +/** + * Success / retry classification for a PI RPC session. + * Write() arguments in traces are never treated as deliverables. + */ + +export function newSettleState() { + return { + retryFailed: false, + providerTransient: false, + lastErr: "", + succeeded: false, + }; +} + +function messageField(ev, key) { + const msg = ev && ev.message; + if (!msg || typeof msg !== "object") { + return ""; + } + const v = msg[key]; + return typeof v === "string" ? v : ""; +} + +export function isTransientText(s) { + const t = String(s || "").toLowerCase(); + return t.includes("connection error") || + t.includes("terminated") || + t.includes("auto_retry") || + t.includes("econnreset") || + t.includes("socket hang up"); +} + +export function isPermanentReason(s) { + const t = String(s || "").toLowerCase(); + return t.includes("prompt_empty") || + t.includes("unauthorized") || + t.includes("治理"); +} + +export function applyAgentEvent(state, ev) { + if (!state || !ev || typeof ev !== "object") { + return state; + } + const t = ev.type || ""; + const stop = messageField(ev, "stopReason"); + const msgErr = messageField(ev, "errorMessage") || + (typeof ev.errorMessage === "string" ? ev.errorMessage : "") || + (typeof ev.finalError === "string" ? ev.finalError : "") || + (ev.error && typeof ev.error.message === "string" ? ev.error.message : ""); + + if (msgErr && isTransientText(msgErr)) { + state.providerTransient = true; + state.lastErr = msgErr; + } + if (stop === "error") { + state.providerTransient = true; + if (msgErr) { + state.lastErr = msgErr; + } + } + if (t === "auto_retry_start") { + state.providerTransient = true; + if (ev.errorMessage) { + state.lastErr = String(ev.errorMessage); + } + } + if (t === "auto_retry_end") { + if (ev.success === false) { + state.retryFailed = true; + state.providerTransient = true; + state.lastErr = ev.finalError || ev.errorMessage || state.lastErr || "auto_retry_failed"; + } else { + state.retryFailed = false; + } + } + if (t === "agent_error") { + const err = (ev.error && ev.error.message) || ev.message || "agent_error"; + const text = typeof err === "string" ? err : "agent_error"; + state.lastErr = text; + if (isTransientText(text)) { + state.providerTransient = true; + } + } + if (t === "agent_settled") { + // PI emits agent_settled even after exhausted SDK retries. + state.succeeded = !state.retryFailed && !state.providerTransient; + } + return state; +} + +export function classifyRunOutcome({ + succeeded = false, + retryFailed = false, + providerTransient = false, + lastErr = "", + missing = [], +} = {}) { + const miss = Array.isArray(missing) ? missing.filter(Boolean) : []; + if (isPermanentReason(lastErr)) { + return { ok: false, retryable: false, fallback_reason: lastErr || "permanent_failure" }; + } + if (!succeeded) { + const reason = lastErr || "agent_not_settled"; + const retryable = (retryFailed || providerTransient || isTransientText(reason)) && + !isPermanentReason(reason); + return { ok: false, retryable, fallback_reason: reason }; + } + if (miss.length > 0) { + return { + ok: false, + retryable: Boolean(retryFailed || providerTransient), + fallback_reason: `missing target deliverable ${miss.join(",")}`, + }; + } + return { ok: true, retryable: false, fallback_reason: "" }; +} diff --git a/deploy/pi-agent/settle-policy.test.mjs b/deploy/pi-agent/settle-policy.test.mjs new file mode 100644 index 00000000..e9aa2a7f --- /dev/null +++ b/deploy/pi-agent/settle-policy.test.mjs @@ -0,0 +1,51 @@ +import { strict as assert } from "node:assert"; +import { applyAgentEvent, classifyRunOutcome, newSettleState } from "./settle-policy.mjs"; + +const ui11Events = [ + { type: "message_end", message: { stopReason: "error", errorMessage: "terminated" } }, + { type: "auto_retry_start", attempt: 1, maxAttempts: 3, errorMessage: "terminated" }, + { type: "message_end", message: { stopReason: "error", errorMessage: "Connection error." } }, + { type: "auto_retry_start", attempt: 2, errorMessage: "Connection error." }, + { type: "auto_retry_start", attempt: 3, errorMessage: "Connection error." }, + { type: "auto_retry_end", success: false, attempt: 3, finalError: "Connection error." }, + { type: "agent_settled" }, +]; + +const state = newSettleState(); +for (const ev of ui11Events) { + applyAgentEvent(state, ev); +} +assert.equal(state.succeeded, false, "agent_settled after auto_retry_end success=false is not success"); +assert.equal(state.providerTransient, true); +assert.equal(state.retryFailed, true); + +const settledFail = classifyRunOutcome({ + ...state, + missing: ["output.md"], + lastErr: "Connection error.", +}); +assert.equal(settledFail.ok, false); +assert.equal(settledFail.retryable, true); +assert.match(settledFail.fallback_reason, /Connection error|terminated|auto_retry/i); + +const cleanMissing = classifyRunOutcome({ + succeeded: true, + retryFailed: false, + providerTransient: false, + lastErr: "", + missing: ["output.md"], +}); +assert.equal(cleanMissing.ok, false); +assert.equal(cleanMissing.retryable, false, "clean settle + missing target is permanent"); +assert.match(cleanMissing.fallback_reason, /missing target deliverable/); + +const promptEmpty = classifyRunOutcome({ + succeeded: false, + retryFailed: false, + providerTransient: false, + lastErr: "prompt_empty", + missing: [], +}); +assert.equal(promptEmpty.retryable, false); + +console.log("settle-policy.test.mjs PASS"); From 7be683d9c75b99589d6e8441f475a681910055c7 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 19:17:09 +0000 Subject: [PATCH 08/13] docs: record UI-11 write-terminated settled-failure root cause Separate MiniMax/SDK connection failures from the product gap that reported process 0 and agent_settled as step success. Co-authored-by: Cursor --- ...6-09-13-document-revision-prelim-output.md | 43 +++++++++++++++---- ...6-09-13-document-revision-prelim-output.md | 15 ++++--- ...6-09-13-document-revision-prelim-output.md | 21 ++++++--- 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/.doc/audit/2026-09-13-document-revision-prelim-output.md b/.doc/audit/2026-09-13-document-revision-prelim-output.md index e3e16c74..ee777fb0 100644 --- a/.doc/audit/2026-09-13-document-revision-prelim-output.md +++ b/.doc/audit/2026-09-13-document-revision-prelim-output.md @@ -17,17 +17,44 @@ severity: high - 小队终态 `TASK_INTERRUPTED` / `task_failed` - 页面:`step step_01_draft missing target deliverable output.md` · `run_1789325979820025769` -## 根因分析 +## 现场时序(只读 workspace / execution.log / trace) -| 层 | 证据 | 问题 | -|---|---|---| -| 模板 | 容器 `/app` 无 `templates/`;工作区 `template.md` 为两段 stub「待确认」 | `GetDocTemplate` 按 cwd 找盘上文件失败后静默回落;writer 只好一次性 `write` 整篇长文 | -| Runner | `tasks/01_draft/execution.log`:`write` 绝对路径 `.../01_draft/output.md` 后 `terminated`;`auto_retry_end success:false`;仍出现 `agent_settled`;文件头 `Outcome: SUCCESS` | `runner.mjs` 把任意 `agent_settled` 当成成功,缺产物仍 `ok:true` | -| 映射 | `internal/agent/piagent.go` 用 `rel_path` 原样做 Patches 键;`workflow.go` 只认 `Patches["output.md"]` 与 `subDir/output.md` | 绝对路径 / `./output.md` / 重复 `tasks/01_draft/output.md` 会被当成缺失 | -| 工作区 | `data/dev/workspaces/bizdev/prelim_design_1789325982486234431` 无任何 `output.md` | 写未落地;不得从截断 write 参数伪造正文 | +工作区:`data/dev/workspaces/bizdev/prelim_design_1789325982486234431/tasks/01_draft/execution.log` + +1. MiniMax **第一次已流出** `write(/app/data/workspaces/.../tasks/01_draft/output.md, content=...)` 的 `toolCall` +2. **同一条** assistant message:`stopReason=error`,`errorMessage=terminated` → 工具未执行,磁盘无 `output.md` +3. SDK `auto_retry_start` attempt 1–3 均为 `Connection error.` +4. `auto_retry_end success=false`,`finalError=Connection error.` +5. 随后仍有 `agent_settled` +6. 同文件头却写 `Outcome: SUCCESS` / `Error: None` +7. 工作区 `trace.jsonl`:`step_01_draft success=true`,`output_digest=e3b0c442...`(空 SHA) +8. PI 子进程退出 0,runner 对上层呈成功;服务端最后因 `output.md` 不存在才失败 + +**禁止**从该 `write` 参数(可能截断)拼正文或补写 `output.md`。 + +## 根因分层 + +必须区分外部瞬时故障与产品韧性缺口,二者同时成立: + +| 层 | 性质 | 证据 | 问题 | +|---|---|---|---| +| MiniMax / PI SDK | **外部 provider 瞬时故障** | 同 message `terminated`;SDK 三次 `Connection error`;`auto_retry_end success=false` | 流式 `write` 未落地;in-process 重试耗尽 | +| 模板 | 产品前置条件 | 容器 `/app` 无 `templates/`;`template.md` 为两段 stub「待确认」 | writer 被迫一次性超长 `write`,拉长流式窗口 | +| `runner.mjs` | **产品韧性缺口** | `agent_settled` 后文件头 `Outcome: SUCCESS` | 把 settle 当成成功;进程 0 ≠ 产物存在 | +| Go `runOneStep` / `AppendTrace` | **产品韧性缺口** | `trace.jsonl` `success=true` + 空 digest | 以 backend `Success` / 进程 0 入账,未按目标文件 fail-closed,也未对瞬时失败做**全新 step invocation** | +| 映射 | 产品契约 | 绝对路径 / `./` / 重复 `tasks/01_draft/output.md` | 即使 write 落地,旧映射也可能认不到 | app 日志:`taskrun_status_transition run_id=run_1789325979820025769 error="step step_01_draft missing target deliverable output.md"`。 +## 成功判定契约(本轮补齐) + +- `agent_settled` 在 `auto_retry_end success=false` / `terminated` / `Connection error` 之后 **不是** 成功 +- 进程退出 0 **不是** 成功 +- 目标文件缺失或空 **不是** 成功 +- 不得执行/回放可能截断的 `write` 参数 +- **瞬时**(provider 信号或 runner `retryable`):有界 **全新** `backend.Execute`(默认 1+1),最终仍 fail-closed +- **永久**(干净 settle 后缺产物、`prompt_empty`、`unauthorized`、治理拦截):不得重试 + ## 严重度 `high` @@ -46,4 +73,4 @@ app 日志:`taskrun_status_transition run_id=run_1789325979820025769 error="st ## 开放问题 - 占槽后真实 UI-11 重跑;本任务保持 `in_progress`,不得标 `done` -- 本轮不重建容器;embed + 契约修复随下次镜像生效 +- 本轮不重建容器;embed + runner 新契约随下次镜像生效。Go 侧必须能在**旧 runner 假成功**时靠 `execution.log` 识别瞬时失败并全新重试 diff --git a/.doc/ops/short-term/2026-09-13-document-revision-prelim-output.md b/.doc/ops/short-term/2026-09-13-document-revision-prelim-output.md index b5bef611..3db27cf1 100644 --- a/.doc/ops/short-term/2026-09-13-document-revision-prelim-output.md +++ b/.doc/ops/short-term/2026-09-13-document-revision-prelim-output.md @@ -13,34 +13,39 @@ mutex: document-revision-prelim-output ## 目标 -第11轮 UI-11:202 + `prelim_design` 已正确,但 `step_01_draft` 缺真实 `output.md`。embed 官方模板、归一化 runner 路径、缺产物 fail-closed。不得标 `done`。 +第11轮 UI-11:202 + `prelim_design` 已正确,但 `step_01_draft` 缺真实 `output.md`。embed 官方模板、归一化 runner 路径、缺产物 fail-closed;对「进程 0 + settled failure / 缺产物」的**瞬时**失败做有界全新 step 重试。不得标 `done`。 ## 范围 - `internal/template` embed + loader -- `internal/agent` 路径映射与目标强制 -- `deploy/pi-agent/runner.mjs` settle 契约 +- `internal/agent` 路径映射、目标强制、瞬时/永久分类、`Result.Retryable` +- `internal/squad` `runOneStep` 有界全新 invocation +- `deploy/pi-agent/runner.mjs` + `settle-policy.mjs` - `deploy/app/Dockerfile` COPY templates/configs(下次镜像) - 文档:audit / remediation / CURRENT ## 非目标 -- 不伪造 `output.md`;不改 E2E prompt;不放宽缺失校验 +- 不伪造 `output.md`;不执行截断 `write` 参数;不改 E2E prompt;不放宽缺失校验 - 不触碰/提交证据目录;不操作/重建容器;本轮不重跑 E2E - 不改 FEATURE-MATRIX;不合 `dev`;不 push +- 不对永久缺失(干净 settle 后无产物、`prompt_empty`、治理)无限重试 ## 步骤 1. [x] 从 `dev@2a58674` 开 `fix/document-revision-prelim-output` 并认领 mutex 2. [x] 定位:stub 模板 + `agent_settled` 假成功 + 只认精确 `output.md` 键 3. [x] embed / 映射 / runner fail-closed + 单测 + 门禁 -4. [ ] 占槽后真实 UI 重跑(本轮不做) +4. [x] 纳入现场时序:write 已流出但 `terminated`;SDK retry 1–3 Connection error;`auto_retry_end success=false`;仍 `agent_settled`;trace `success=true` +5. [x] settle-policy + Go 分类 + `runOneStep` 有界全新重试 + 单测 +6. [ ] 占槽后真实 UI 重跑(本轮不做) ## 验收标准 - [ ] 无盘模板时 `prelim_design` 仍为官方「系统初步设计方案」 - [ ] 绝对/`./`/重复子目录 `rel_path` 能落到目标文件 - [ ] `ok:true` 但无非空目标 → 失败,不发明正文 +- [x] 瞬时 settled failure → 最多一次全新 step;永久缺失不重试;耗尽 fail-closed - [ ] intake 工作流单测仍绿 - [ ] 真实 UI-11 PASS 后才可标 `done` diff --git a/.doc/remediation/2026-09-13-document-revision-prelim-output.md b/.doc/remediation/2026-09-13-document-revision-prelim-output.md index 16f4a586..1c72a322 100644 --- a/.doc/remediation/2026-09-13-document-revision-prelim-output.md +++ b/.doc/remediation/2026-09-13-document-revision-prelim-output.md @@ -1,5 +1,5 @@ --- -title: "prelim_design 真实产物契约:embed 模板 + 路径映射 + 缺产物 fail-closed" +title: "prelim_design 真实产物契约:embed + 映射 + settle fail-closed + 瞬时全新 step 有界重试" status: in_progress created: 2026-09-13 updated: 2026-09-13 @@ -11,26 +11,35 @@ updated: 2026-09-13 链到 [../audit/2026-09-13-document-revision-prelim-output.md](../audit/2026-09-13-document-revision-prelim-output.md)。 +外部:MiniMax 首次 `write` 未执行(`terminated`)+ SDK 三次 `Connection error`。 +产品:`agent_settled` / 进程 0 / `trace success=true` 被当成步成功,没有全新 step 重试。 + ## 目标 -通用、fail-safe 的产物契约:成功步必须有真实、非空的目标文件。不得伪造 `output.md`、不得改 E2E prompt、不得放宽缺失校验。intake 与其他 `task_key` 不回归。 +通用、fail-closed 的产物与重试契约:成功步必须有真实、非空目标文件。瞬时 provider 失败可做有界**全新 step invocation**。永久缺失不得空转重试。不得伪造 `output.md`、不得改 E2E prompt、不得从截断 `write` 参数落盘。 ## 分波次计划 | 波次 | 内容 | 验收 | |------|------|------| -| Wave 0 | `//go:embed` 官方 `templates/docs/*.md`;`GetDocTemplate` 先读 embed | 无盘上 `templates/` 时 `prelim_design` / `intake` 仍是正式模板,不是两段 stub | -| Wave 0 | Runner `rel_path` 归一化到目标名;缺产物则 `Success=false` | 绝对路径 / `./` / 重复子目录仍能落盘;空 patches 仍失败 | -| Wave 0 | `runner.mjs`:`auto_retry_end` 失败或目标文件不存在不得 `ok:true` | 与现场 `agent_settled` 假成功对齐 | +| Wave 0 | `//go:embed` 官方 `templates/docs/*.md`;`GetDocTemplate` 先读 embed | 无盘上 `templates/` 时仍是正式模板 | +| Wave 0 | Runner `rel_path` 归一化;缺产物则 `Success=false` | 绝对 / `./` / 重复子目录能落盘;空 patches 失败 | +| Wave 0 | `runner.mjs`:`auto_retry_end` 失败或目标不存在不得 `ok:true` | 与现场 `agent_settled` 假成功对齐 | +| Wave 1 | `settle-policy`:`terminated` / `Connection error` / `auto_retry_end` 后 settle 失败,返回 `retryable` | 干净 settle + 缺文件 → `retryable=false` | +| Wave 1 | Go `AssessStepFailure` + `runOneStep` 有界全新 invocation(≤2) | 瞬时可恢复;永久缺失只调用 1 次;耗尽仍 fail-closed | +| Wave 1 | 旧 runner 假成功:扫描 `execution.log` 识别 settled failure | 不依赖本轮 rebuild 容器 | ## 取舍 - 不从 `execution.log` 截断的 `write` 参数拼出正文。 - 不改 UI-11 prompt。Dockerfile 补 `COPY templates/configs` 仅供下次镜像,本轮不 rebuild。 +- 产品重试是**新的** `backend.Execute` / 新 PI 进程,不是回放 SDK 半截 session,也不是执行截断 toolCall。 +- 超时(整步 timeout)不按瞬时重试,避免 2×30min。 ## 验收标准 -- [x] embed + 路径映射 + runner fail-closed + 单测(门禁见提交说明) +- [x] embed + 路径映射 + runner fail-closed + 单测 +- [x] 瞬时/永久分类 + 有界全新 step 重试 + 单测(本机门禁;真实 UI 未重跑) - [ ] 占槽后真实 UI-11 PASS 才可将本任务标 `done` ## 相关链接 From 35b2e922bfa183eb188a81604c4fdc18cebd9ca4 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 19:21:56 +0000 Subject: [PATCH 09/13] fix(deploy): keep official templates in the image build context *.md was excluding templates/docs from Docker context, so COPY and go:embed could not see prelim_design.md at /app. Un-ignore those trees and assert the smoke image can cat the official file. Co-authored-by: Cursor --- .dockerignore | 2 + deploy/app/Dockerfile | 2 + internal/template/docker_context_test.go | 221 +++++++++++++++++++++++ internal/template/loader.go | 18 +- 4 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 internal/template/docker_context_test.go diff --git a/.dockerignore b/.dockerignore index 37225ead..cc9436c9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,6 +10,8 @@ web/dist web/.vite bin *.md +!templates/**/*.md +!configs/**/*.md .env .env.* !.env.example diff --git a/deploy/app/Dockerfile b/deploy/app/Dockerfile index b582061d..3fa1d126 100644 --- a/deploy/app/Dockerfile +++ b/deploy/app/Dockerfile @@ -30,6 +30,8 @@ WORKDIR /app COPY --from=go-builder /server /app/server COPY --from=web-builder /web/dist /app/web/dist COPY deploy/postgres/goose /app/deploy/postgres/goose +# Official markdown must be on disk at /app/templates/docs and /app/configs. +# .dockerignore keeps *.md out of context except these two trees. COPY templates /app/templates COPY configs /app/configs diff --git a/internal/template/docker_context_test.go b/internal/template/docker_context_test.go new file mode 100644 index 00000000..5cdc0476 --- /dev/null +++ b/internal/template/docker_context_test.go @@ -0,0 +1,221 @@ +package template_test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "chat-agent-system/internal/template" +) + +func repoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller") + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) +} + +func TestDockerfileCopiesTemplatesAndConfigsToApp(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(repoRoot(t), "deploy", "app", "Dockerfile")) + if err != nil { + t.Fatal(err) + } + text := string(raw) + for _, line := range []string{ + "COPY templates /app/templates", + "COPY configs /app/configs", + "WORKDIR /app", + } { + if !strings.Contains(text, line) { + t.Fatalf("deploy/app/Dockerfile must contain %q so the image runtime can read official docs", line) + } + } +} + +func TestDockerignoreKeepsOfficialTemplateDocs(t *testing.T) { + root := repoRoot(t) + patterns := readDockerignore(t, filepath.Join(root, ".dockerignore")) + docs, err := filepath.Glob(filepath.Join(root, "templates", "docs", "*.md")) + if err != nil || len(docs) == 0 { + t.Fatalf("templates/docs/*.md missing on disk: %v", err) + } + for _, abs := range docs { + rel, err := filepath.Rel(root, abs) + if err != nil { + t.Fatal(err) + } + rel = filepath.ToSlash(rel) + if dockerIgnored(patterns, rel) { + t.Fatalf(".dockerignore excludes %s; COPY templates and go:embed docs/*.md cannot see it in the image", rel) + } + } + if dockerIgnored(patterns, "templates/embed.go") { + t.Fatal("templates/embed.go must stay in the go-builder context") + } + if dockerIgnored(patterns, "configs/agents/writer.md") { + t.Fatal("configs/agents/*.md must stay in the image for GetAgentInstruction") + } +} + +func TestGetDocTemplate_AppRootLayout(t *testing.T) { + root := repoRoot(t) + app := filepath.Join(t.TempDir(), "app") + dest := filepath.Join(app, "templates", "docs") + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatal(err) + } + src := filepath.Join(root, "templates", "docs", "prelim_design.md") + body, err := os.ReadFile(src) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dest, "prelim_design.md"), body, 0o644); err != nil { + t.Fatal(err) + } + agentDir := filepath.Join(app, "configs", "agents") + if err := os.MkdirAll(agentDir, 0o755); err != nil { + t.Fatal(err) + } + writer, err := os.ReadFile(filepath.Join(root, "configs", "agents", "writer.md")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentDir, "writer.md"), writer, 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("APP_ROOT", app) + t.Chdir(app) + got, err := template.GetDocTemplate("prelim_design") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "系统初步设计方案") || !strings.Contains(got, "子系统划分") { + t.Fatalf(" /app layout must yield the official template, got %d bytes", len(got)) + } + if strings.Contains(got, "## 1. 结论") && !strings.Contains(got, "子系统划分") { + t.Fatal("must not use the two-section stub") + } + inst, err := template.GetAgentInstruction("writer") + if err != nil { + t.Fatalf("image /app/configs/agents must be readable: %v", err) + } + if strings.TrimSpace(inst) == "" { + t.Fatal("writer instruction empty") + } +} + +func TestDockerBuildContext_TemplatesDocsReadable(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI not available") + } + root := repoRoot(t) + df := filepath.Join(t.TempDir(), "Dockerfile") + // Same context and COPY destinations as deploy/app/Dockerfile final stage. + if err := os.WriteFile(df, []byte("FROM debian:bookworm-slim\nWORKDIR /app\nCOPY templates /app/templates\nCOPY configs /app/configs\n"), 0o644); err != nil { + t.Fatal(err) + } + tag := "agent-system-templates-smoke:local" + build := exec.Command("docker", "build", "-f", df, "-t", tag, ".") + build.Dir = root + out, err := runTimed(build, 3*time.Minute) + if err != nil { + t.Fatalf("docker build context failed: %v\n%s", err, out) + } + t.Cleanup(func() { + _ = exec.Command("docker", "rmi", "-f", tag).Run() + }) + + cat := exec.Command("docker", "run", "--rm", "--entrypoint", "cat", tag, "/app/templates/docs/prelim_design.md") + body, err := runTimed(cat, 30*time.Second) + if err != nil { + t.Fatalf("image missing /app/templates/docs/prelim_design.md: %v\n%s", err, body) + } + if !bytes.Contains(body, []byte("系统初步设计方案")) || !bytes.Contains(body, []byte("子系统划分")) { + t.Fatalf("image template is not the official prelim_design (%d bytes)", len(body)) + } + ls := exec.Command("docker", "run", "--rm", "--entrypoint", "ls", tag, "/app/configs/agents") + agents, err := runTimed(ls, 30*time.Second) + if err != nil { + t.Fatalf("image missing /app/configs/agents: %v\n%s", err, agents) + } + if !bytes.Contains(agents, []byte("writer.md")) { + t.Fatalf("image configs/agents incomplete: %s", agents) + } +} + +func runTimed(cmd *exec.Cmd, d time.Duration) ([]byte, error) { + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + if err := cmd.Start(); err != nil { + return buf.Bytes(), err + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + return buf.Bytes(), err + case <-time.After(d): + _ = cmd.Process.Kill() + return buf.Bytes(), os.ErrDeadlineExceeded + } +} + +func readDockerignore(t *testing.T, path string) []string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return strings.Split(string(raw), "\n") +} + +func dockerIgnored(patterns []string, rel string) bool { + rel = filepath.ToSlash(rel) + ignored := false + for _, raw := range patterns { + p := strings.TrimSpace(raw) + if p == "" || strings.HasPrefix(p, "#") { + continue + } + negate := false + if strings.HasPrefix(p, "!") { + negate = true + p = strings.TrimPrefix(p, "!") + } + if matchDockerPattern(p, rel) { + ignored = !negate + } + } + return ignored +} + +func matchDockerPattern(pattern, rel string) bool { + pattern = filepath.ToSlash(pattern) + if pattern == rel { + return true + } + if strings.HasSuffix(pattern, "/**/*.md") { + prefix := strings.TrimSuffix(pattern, "/**/*.md") + return strings.HasPrefix(rel, prefix+"/") && strings.HasSuffix(rel, ".md") + } + if pattern == "*.md" { + return strings.HasSuffix(rel, ".md") + } + if !strings.Contains(pattern, "/") && !strings.Contains(pattern, "*") { + return rel == pattern || strings.HasPrefix(rel, pattern+"/") + } + ok, _ := filepath.Match(pattern, rel) + if ok { + return true + } + ok, _ = filepath.Match(pattern, filepath.Base(rel)) + return ok +} diff --git a/internal/template/loader.go b/internal/template/loader.go index 2d825343..1b93e5d6 100644 --- a/internal/template/loader.go +++ b/internal/template/loader.go @@ -11,17 +11,29 @@ import ( func findProjectRoot() string { wd, _ := os.Getwd() + candidates := []string{wd} dir := wd for i := 0; i < 10; i++ { - if _, err := os.Stat(filepath.Join(dir, "templates", "docs")); err == nil { - return dir - } + candidates = append(candidates, dir) parent := filepath.Dir(dir) if parent == dir { break } dir = parent } + if env := strings.TrimSpace(os.Getenv("APP_ROOT")); env != "" { + candidates = append(candidates, env) + } + // Image WORKDIR is /app; Dockerfile copies templates/configs here. + candidates = append(candidates, "/app") + for _, root := range candidates { + if root == "" { + continue + } + if _, err := os.Stat(filepath.Join(root, "templates", "docs")); err == nil { + return root + } + } return wd } From 6fd685fd476645eebb8eb4516a8e6adc8b4579bb Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 19:21:56 +0000 Subject: [PATCH 10/13] fix(service): retry only safe transients and keep existing targets Fresh step invocation is limited to provider terminated/connection plus a missing target, or an explicit runner failure. A deliverable already on disk is accepted and never overwritten. Co-authored-by: Cursor --- internal/agent/step_retry.go | 38 ++++++++++++++++++------------- internal/agent/step_retry_test.go | 16 +++++++++++++ internal/squad/step_retry_test.go | 27 ++++++++++++++++++++++ internal/squad/workflow.go | 29 ++++++++++++++++++----- 4 files changed, 88 insertions(+), 22 deletions(-) diff --git a/internal/agent/step_retry.go b/internal/agent/step_retry.go index ce46e491..860616e9 100644 --- a/internal/agent/step_retry.go +++ b/internal/agent/step_retry.go @@ -44,6 +44,9 @@ type StepFailureSignals struct { WorkDir string ExtraText string TargetMissing bool + // TargetExists is a non-empty deliverable already on disk. Retry must + // not overwrite it. + TargetExists bool } type FailureAssessment struct { @@ -106,27 +109,25 @@ func AssessStepFailure(sig StepFailureSignals) FailureAssessment { lower := strings.ToLower(reason) trace := ScanTraceText(sig.ExtraText + "\n" + ReadWorkDirTraces(sig.WorkDir)) missing := sig.TargetMissing || strings.Contains(lower, "missing target deliverable") - - if isPermanentReason(lower) && !trace.ProviderTransient { - return FailureAssessment{Class: FailurePermanent, Reason: "permanent: " + compactReason(reason)} + if sig.TargetExists { + return FailureAssessment{Class: FailurePermanent, Reason: "target-already-present: do not overwrite"} } - if isPermanentReason(lower) && !missing { + if isPermanentReason(lower) { return FailureAssessment{Class: FailurePermanent, Reason: "permanent: " + compactReason(reason)} } - if sig.Result != nil && sig.Result.Retryable { - return FailureAssessment{Class: FailureTransient, Reason: "runner-retryable: " + compactReason(reason)} + runnerFailed := (sig.Result != nil && sig.Result.Retryable) || isRunnerExplicitFailure(lower) + provider := trace.ProviderTransient || hasProviderTermOrConn(lower) + if runnerFailed { + return FailureAssessment{Class: FailureTransient, Reason: "runner-explicit-failure: " + compactReason(reason)} } - if trace.ProviderTransient { - kind := "provider-transient" - if missing && trace.Settled { + if provider && missing { + kind := "provider-transient-missing-target" + if trace.Settled { kind = "settled-failure-missing-target" } return FailureAssessment{Class: FailureTransient, Reason: kind + ": " + compactReason(reason)} } - if hasTransientKeyword(lower) { - return FailureAssessment{Class: FailureTransient, Reason: "provider-transient: " + compactReason(reason)} - } if missing { return FailureAssessment{Class: FailurePermanent, Reason: "permanent-missing: " + compactReason(reason)} } @@ -156,13 +157,18 @@ func isPermanentReason(lower string) bool { strings.Contains(lower, "治理拦截") } -func hasTransientKeyword(lower string) bool { +func hasProviderTermOrConn(lower string) bool { return strings.Contains(lower, "connection error") || strings.Contains(lower, "terminated") || - strings.Contains(lower, "auto_retry") || strings.Contains(lower, "econnreset") || - strings.Contains(lower, "socket hang up") || - strings.Contains(lower, "pi remote runner call failed") + strings.Contains(lower, "socket hang up") +} + +func isRunnerExplicitFailure(lower string) bool { + return strings.Contains(lower, "pi remote runner call failed") || + strings.Contains(lower, "auto_retry_failed") || + strings.Contains(lower, "auto_retry_end") || + (hasProviderTermOrConn(lower) && !strings.Contains(lower, "missing target deliverable")) } func compactReason(s string) string { diff --git a/internal/agent/step_retry_test.go b/internal/agent/step_retry_test.go index f1a0c1f6..853f16bf 100644 --- a/internal/agent/step_retry_test.go +++ b/internal/agent/step_retry_test.go @@ -103,6 +103,22 @@ func TestAssessStepFailure_TransientVsPermanent(t *testing.T) { sig: StepFailureSignals{Result: &Result{Success: false, ErrorReason: "pi remote runner call failed: connection refused"}}, want: FailureTransient, }, + { + name: "existing target is never retried", + sig: StepFailureSignals{ + Result: &Result{Success: false, ErrorReason: "Connection error.", Retryable: true}, + TargetExists: true, + }, + want: FailurePermanent, + }, + { + name: "provider signal without missing is not enough", + sig: StepFailureSignals{ + Result: &Result{Success: false, ErrorReason: "agent_not_settled"}, + ExtraText: `{"type":"auto_retry_end","success":false}`, + }, + want: FailurePermanent, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/squad/step_retry_test.go b/internal/squad/step_retry_test.go index c88aae97..7966fc4e 100644 --- a/internal/squad/step_retry_test.go +++ b/internal/squad/step_retry_test.go @@ -66,6 +66,11 @@ func (b *countingBackend) Execute(ctx context.Context, req agent.Request) (*agen case "always-transient": return &agent.Result{Success: false, ErrorReason: failText, Retryable: true}, nil + case "fail-after-writing-target": + body := []byte("# already on disk\n\ndo not overwrite\n") + _ = os.WriteFile(filepath.Join(req.WorkDir, "output.md"), body, 0o644) + return &agent.Result{Success: false, ErrorReason: "Connection error.", Retryable: true}, nil + default: return &agent.Result{Success: false, ErrorReason: "unknown mode"}, nil } @@ -165,6 +170,28 @@ func TestWorkflowDriver_DoesNotRetryPermanentMissing(t *testing.T) { } } +func TestWorkflowDriver_DoesNotOverwriteExistingTarget(t *testing.T) { + wsDir, driver := prepareSingleWriterWS(t) + backend := &countingBackend{mode: "fail-after-writing-target"} + res, err := driver.Execute(context.Background(), wsDir, backend, squad.ExecuteOptions{Timeout: 5 * time.Second}) + if err != nil { + t.Fatalf("existing target must be accepted, not retried: %v", err) + } + if !res.Success { + t.Fatalf("want success from existing file, got %s", res.ErrorReason) + } + if backend.calls != 1 { + t.Fatalf("must not re-invoke over an existing target, calls=%d", backend.calls) + } + body, err := os.ReadFile(filepath.Join(wsDir, "tasks", "01_draft", "output.md")) + if err != nil { + t.Fatal(err) + } + if string(body) != "# already on disk\n\ndo not overwrite\n" { + t.Fatalf("target was rewritten: %s", body) + } +} + func TestWorkflowDriver_BoundedTransientRetryThenFailClosed(t *testing.T) { wsDir, driver := prepareSingleWriterWS(t) backend := &countingBackend{mode: "always-transient", failText: "Connection error."} diff --git a/internal/squad/workflow.go b/internal/squad/workflow.go index c5e2e9f9..5a279e7a 100644 --- a/internal/squad/workflow.go +++ b/internal/squad/workflow.go @@ -511,6 +511,16 @@ func (d *WorkflowDriver) runOneStep( ok bool ) for attempt := 1; attempt <= agent.MaxTransientStepInvocations; attempt++ { + onDisk, _ := os.ReadFile(prevOutPath) + produced := len(strings.TrimSpace(string(onDisk))) > 0 && string(onDisk) != prevContent + if attempt > 1 && produced { + ok = true + matErr = nil + if res != nil { + res.Success = true + } + break + } if attempt > 1 && streamer != nil { streamer.EmitPhase( "step_retrying", @@ -534,18 +544,24 @@ func (d *WorkflowDriver) runOneStep( } } + onDisk, _ = os.ReadFile(prevOutPath) + produced = len(strings.TrimSpace(string(onDisk))) > 0 && string(onDisk) != prevContent newContent := "" - if res != nil { + if produced { + newContent = string(onDisk) + } else if res != nil { if b, has := res.Patches[deliverableName]; has { newContent = string(b) } } if newContent == "" { - if b, rErr := os.ReadFile(prevOutPath); rErr == nil { - newContent = string(b) - } + newContent = string(onDisk) + } + ok = (execErr == nil && res != nil && res.Success && matErr == nil) || produced + if produced && res != nil { + res.Success = true + matErr = nil } - ok = execErr == nil && res != nil && res.Success && matErr == nil sim := OutputSimilarity(prevContent, newContent) if mu != nil { mu.Lock() @@ -588,7 +604,8 @@ func (d *WorkflowDriver) runOneStep( Err: execErr, Result: res, WorkDir: subDir, - TargetMissing: matErr != nil || (res != nil && strings.Contains(res.ErrorReason, "missing target deliverable")), + TargetMissing: !produced && (matErr != nil || (res != nil && strings.Contains(res.ErrorReason, "missing target deliverable"))), + TargetExists: produced, }) if assess.Class != agent.FailureTransient || attempt >= agent.MaxTransientStepInvocations { break From d9c1791e5665929b973f169fd68f578dcf4e6040 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 19:21:56 +0000 Subject: [PATCH 11/13] docs: record dockerignore hole and tightened step retry contract Co-authored-by: Cursor --- .../2026-09-13-document-revision-prelim-output.md | 5 +++-- .doc/ops/CURRENT.md | 4 ++-- .../2026-09-13-document-revision-prelim-output.md | 9 ++++++--- .../2026-09-13-document-revision-prelim-output.md | 10 ++++++---- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/.doc/audit/2026-09-13-document-revision-prelim-output.md b/.doc/audit/2026-09-13-document-revision-prelim-output.md index ee777fb0..a3872b8b 100644 --- a/.doc/audit/2026-09-13-document-revision-prelim-output.md +++ b/.doc/audit/2026-09-13-document-revision-prelim-output.md @@ -39,7 +39,7 @@ severity: high | 层 | 性质 | 证据 | 问题 | |---|---|---|---| | MiniMax / PI SDK | **外部 provider 瞬时故障** | 同 message `terminated`;SDK 三次 `Connection error`;`auto_retry_end success=false` | 流式 `write` 未落地;in-process 重试耗尽 | -| 模板 | 产品前置条件 | 容器 `/app` 无 `templates/`;`template.md` 为两段 stub「待确认」 | writer 被迫一次性超长 `write`,拉长流式窗口 | +| 模板 | 产品前置条件 | `.dockerignore` 的 `*.md` 把 `templates/docs/*.md` 排除出 build context;即使 `COPY templates /app/templates`,镜像内仍无官方稿,`GetDocTemplate` 失败后 `workspace.go` 回落两段 stub「待确认」 | writer 被迫一次性超长 `write`,拉长流式窗口 | | `runner.mjs` | **产品韧性缺口** | `agent_settled` 后文件头 `Outcome: SUCCESS` | 把 settle 当成成功;进程 0 ≠ 产物存在 | | Go `runOneStep` / `AppendTrace` | **产品韧性缺口** | `trace.jsonl` `success=true` + 空 digest | 以 backend `Success` / 进程 0 入账,未按目标文件 fail-closed,也未对瞬时失败做**全新 step invocation** | | 映射 | 产品契约 | 绝对路径 / `./` / 重复 `tasks/01_draft/output.md` | 即使 write 落地,旧映射也可能认不到 | @@ -73,4 +73,5 @@ app 日志:`taskrun_status_transition run_id=run_1789325979820025769 error="st ## 开放问题 - 占槽后真实 UI-11 重跑;本任务保持 `in_progress`,不得标 `done` -- 本轮不重建容器;embed + runner 新契约随下次镜像生效。Go 侧必须能在**旧 runner 假成功**时靠 `execution.log` 识别瞬时失败并全新重试 +- 本轮不执行 `env-service` 重建;Dockerfile / `.dockerignore` 已按 `/app/templates/docs` 读路径修好并做 context 实测。占槽后的镜像重建才带进运行中的 dev 容器 +- Go 侧必须能在**旧 runner 假成功**时靠 `execution.log` 识别瞬时失败并全新重试;仅修 runner 假成功只会更早 FAIL,不会单独提高成功率 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 771d2da8..dd094c69 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -1,11 +1,11 @@ # 当前焦点 **更新**: 2026-09-13 -**production_ready**: no(DOCREV:schema **30**;第11轮 UI-11 prelim output 契约;不操作容器) +**production_ready**: no(DOCREV:schema **30**;第11轮 UI-11 prelim output 契约;编码可改 Dockerfile / 测 build context,不执行 env-service 重建) ## 焦点 -1. **DOCREV-RT**: [prelim_design 缺 output.md 产物契约修复](short-term/2026-09-13-document-revision-prelim-output.md)(`fix/document-revision-prelim-output`:不伪造产物 / 不改 prompt / 不重跑 E2E / 不重建容器;仍 `in_progress`) +1. **DOCREV-RT**: [prelim_design 缺 output.md 产物契约修复](short-term/2026-09-13-document-revision-prelim-output.md)(`fix/document-revision-prelim-output`:Dockerfile + dockerignore 已测 context;有界全新 step 重试;不伪造产物 / 不改 prompt / 不重跑 E2E / 不执行 env-service 重建;仍 `in_progress`) 2. **CHAT**: [刷新恢复整改已实施](short-term/2026-09-13-chat-history-refresh-remediation.md)(待合 dev + PI E2E 补录) 3. **CI**: `TEST_DATABASE_URL` + 真实 PI E2E 实录;INTENT 发布 blocker 见 conversation-intent short-term diff --git a/.doc/ops/short-term/2026-09-13-document-revision-prelim-output.md b/.doc/ops/short-term/2026-09-13-document-revision-prelim-output.md index 3db27cf1..eb4e657a 100644 --- a/.doc/ops/short-term/2026-09-13-document-revision-prelim-output.md +++ b/.doc/ops/short-term/2026-09-13-document-revision-prelim-output.md @@ -21,13 +21,14 @@ mutex: document-revision-prelim-output - `internal/agent` 路径映射、目标强制、瞬时/永久分类、`Result.Retryable` - `internal/squad` `runOneStep` 有界全新 invocation - `deploy/pi-agent/runner.mjs` + `settle-policy.mjs` -- `deploy/app/Dockerfile` COPY templates/configs(下次镜像) +- `deploy/app/Dockerfile` `COPY templates` / `COPY configs` 到 `/app`;`.dockerignore` 放行官方 markdown +- Docker build context / `/app/templates/docs` 读路径实测(不执行 env-service 重建) - 文档:audit / remediation / CURRENT ## 非目标 - 不伪造 `output.md`;不执行截断 `write` 参数;不改 E2E prompt;不放宽缺失校验 -- 不触碰/提交证据目录;不操作/重建容器;本轮不重跑 E2E +- 不触碰/提交证据目录;不执行 env-service 重建/重启;本轮不重跑 E2E - 不改 FEATURE-MATRIX;不合 `dev`;不 push - 不对永久缺失(干净 settle 后无产物、`prompt_empty`、治理)无限重试 @@ -38,7 +39,9 @@ mutex: document-revision-prelim-output 3. [x] embed / 映射 / runner fail-closed + 单测 + 门禁 4. [x] 纳入现场时序:write 已流出但 `terminated`;SDK retry 1–3 Connection error;`auto_retry_end success=false`;仍 `agent_settled`;trace `success=true` 5. [x] settle-policy + Go 分类 + `runOneStep` 有界全新重试 + 单测 -6. [ ] 占槽后真实 UI 重跑(本轮不做) +6. [x] `.dockerignore` 放行 `templates/**/*.md`;Docker context 实测 `/app/templates/docs/prelim_design.md` +7. [x] 已有目标不覆盖;仅 provider+缺文件或 runner 明确失败才重试 +8. [ ] 占槽后真实 UI 重跑(本轮不做) ## 验收标准 diff --git a/.doc/remediation/2026-09-13-document-revision-prelim-output.md b/.doc/remediation/2026-09-13-document-revision-prelim-output.md index 1c72a322..d8677802 100644 --- a/.doc/remediation/2026-09-13-document-revision-prelim-output.md +++ b/.doc/remediation/2026-09-13-document-revision-prelim-output.md @@ -23,23 +23,25 @@ updated: 2026-09-13 | 波次 | 内容 | 验收 | |------|------|------| | Wave 0 | `//go:embed` 官方 `templates/docs/*.md`;`GetDocTemplate` 先读 embed | 无盘上 `templates/` 时仍是正式模板 | +| Wave 0 | `.dockerignore` 放行 `templates/**/*.md` 与 `configs/**/*.md`;`COPY templates/configs` 到 `/app` | `TestDockerBuildContext_TemplatesDocsReadable`:镜像内可读官方 `prelim_design.md` | | Wave 0 | Runner `rel_path` 归一化;缺产物则 `Success=false` | 绝对 / `./` / 重复子目录能落盘;空 patches 失败 | | Wave 0 | `runner.mjs`:`auto_retry_end` 失败或目标不存在不得 `ok:true` | 与现场 `agent_settled` 假成功对齐 | | Wave 1 | `settle-policy`:`terminated` / `Connection error` / `auto_retry_end` 后 settle 失败,返回 `retryable` | 干净 settle + 缺文件 → `retryable=false` | -| Wave 1 | Go `AssessStepFailure` + `runOneStep` 有界全新 invocation(≤2) | 瞬时可恢复;永久缺失只调用 1 次;耗尽仍 fail-closed | -| Wave 1 | 旧 runner 假成功:扫描 `execution.log` 识别 settled failure | 不依赖本轮 rebuild 容器 | +| Wave 1 | Go `AssessStepFailure` + `runOneStep` 有界全新 invocation(≤2) | 仅 `(provider terminated/connection + 缺目标)` 或 runner 明确失败;已有目标不覆盖;永久缺失 1 次;耗尽 fail-closed | +| Wave 1 | 旧 runner 假成功:扫描 `execution.log` 识别 settled failure | 不依赖本轮 env-service 重建 | ## 取舍 - 不从 `execution.log` 截断的 `write` 参数拼出正文。 -- 不改 UI-11 prompt。Dockerfile 补 `COPY templates/configs` 仅供下次镜像,本轮不 rebuild。 +- 不改 UI-11 prompt。Dockerfile / dockerignore 已修并测 context;不执行 env-service 重建。 - 产品重试是**新的** `backend.Execute` / 新 PI 进程,不是回放 SDK 半截 session,也不是执行截断 toolCall。 - 超时(整步 timeout)不按瞬时重试,避免 2×30min。 ## 验收标准 - [x] embed + 路径映射 + runner fail-closed + 单测 -- [x] 瞬时/永久分类 + 有界全新 step 重试 + 单测(本机门禁;真实 UI 未重跑) +- [x] dockerignore 放行官方 markdown;Docker context 可读 `/app/templates/docs/prelim_design.md` +- [x] 瞬时/永久分类 + 有界全新 step 重试 + 已有目标不覆盖 + 单测(本机门禁;真实 UI 未重跑) - [ ] 占槽后真实 UI-11 PASS 才可将本任务标 `done` ## 相关链接 From becd295c9d495bdacf63fdadfc92036f63caf199 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 19:24:47 +0000 Subject: [PATCH 12/13] fix(service): keep explicit backend failure fail-closed A this-invocation output.md is preserved and never retried over, but Success=false stays a step failure so truncated writes cannot pass. Prior-round residue without a trusted patch is also not success. Co-authored-by: Cursor --- ...6-09-13-document-revision-prelim-output.md | 2 +- ...6-09-13-document-revision-prelim-output.md | 2 +- internal/agent/step_retry.go | 5 +- internal/squad/evaluator_test.go | 7 ++- internal/squad/step_retry_test.go | 40 +++++++++++-- internal/squad/workflow.go | 56 ++++++++++++------- 6 files changed, 82 insertions(+), 30 deletions(-) diff --git a/.doc/ops/short-term/2026-09-13-document-revision-prelim-output.md b/.doc/ops/short-term/2026-09-13-document-revision-prelim-output.md index eb4e657a..121557a0 100644 --- a/.doc/ops/short-term/2026-09-13-document-revision-prelim-output.md +++ b/.doc/ops/short-term/2026-09-13-document-revision-prelim-output.md @@ -40,7 +40,7 @@ mutex: document-revision-prelim-output 4. [x] 纳入现场时序:write 已流出但 `terminated`;SDK retry 1–3 Connection error;`auto_retry_end success=false`;仍 `agent_settled`;trace `success=true` 5. [x] settle-policy + Go 分类 + `runOneStep` 有界全新重试 + 单测 6. [x] `.dockerignore` 放行 `templates/**/*.md`;Docker context 实测 `/app/templates/docs/prelim_design.md` -7. [x] 已有目标不覆盖;仅 provider+缺文件或 runner 明确失败才重试 +7. [x] 本次写入的目标不覆盖、不再调用;backend 明确失败仍 FAIL(不把文件存在当成功);上一轮残留不当成功 8. [ ] 占槽后真实 UI 重跑(本轮不做) ## 验收标准 diff --git a/.doc/remediation/2026-09-13-document-revision-prelim-output.md b/.doc/remediation/2026-09-13-document-revision-prelim-output.md index d8677802..0af3b25f 100644 --- a/.doc/remediation/2026-09-13-document-revision-prelim-output.md +++ b/.doc/remediation/2026-09-13-document-revision-prelim-output.md @@ -27,7 +27,7 @@ updated: 2026-09-13 | Wave 0 | Runner `rel_path` 归一化;缺产物则 `Success=false` | 绝对 / `./` / 重复子目录能落盘;空 patches 失败 | | Wave 0 | `runner.mjs`:`auto_retry_end` 失败或目标不存在不得 `ok:true` | 与现场 `agent_settled` 假成功对齐 | | Wave 1 | `settle-policy`:`terminated` / `Connection error` / `auto_retry_end` 后 settle 失败,返回 `retryable` | 干净 settle + 缺文件 → `retryable=false` | -| Wave 1 | Go `AssessStepFailure` + `runOneStep` 有界全新 invocation(≤2) | 仅 `(provider terminated/connection + 缺目标)` 或 runner 明确失败;已有目标不覆盖;永久缺失 1 次;耗尽 fail-closed | +| Wave 1 | Go `AssessStepFailure` + `runOneStep` 有界全新 invocation(≤2) | 仅 `(provider terminated/connection + 缺目标)` 或 runner 明确失败;本次写入不覆盖且失败不翻成功;上一轮残留不当成功;耗尽 fail-closed | | Wave 1 | 旧 runner 假成功:扫描 `execution.log` 识别 settled failure | 不依赖本轮 env-service 重建 | ## 取舍 diff --git a/internal/agent/step_retry.go b/internal/agent/step_retry.go index 860616e9..fab26348 100644 --- a/internal/agent/step_retry.go +++ b/internal/agent/step_retry.go @@ -44,8 +44,9 @@ type StepFailureSignals struct { WorkDir string ExtraText string TargetMissing bool - // TargetExists is a non-empty deliverable already on disk. Retry must - // not overwrite it. + // TargetExists means this invocation wrote a non-empty file that differs + // from the pre-invoke baseline. Do not retry or overwrite it. The step + // still fails unless the backend already reported a trusted success. TargetExists bool } diff --git a/internal/squad/evaluator_test.go b/internal/squad/evaluator_test.go index f5619248..7e3d8a0d 100644 --- a/internal/squad/evaluator_test.go +++ b/internal/squad/evaluator_test.go @@ -1,6 +1,7 @@ package squad import ( + "bytes" "context" "os" "path/filepath" @@ -235,5 +236,9 @@ func (b *testEchoBackend) Execute(ctx context.Context, req agent.Request) (*agen if _, err := os.Stat(p); os.IsNotExist(err) { _ = os.WriteFile(p, []byte("## 步骤执行交付产物\n\n已完成任务要求论证与数据沉淀。\n"), 0o644) } - return &agent.Result{Success: true}, nil + content, err := os.ReadFile(p) + if err != nil || len(bytes.TrimSpace(content)) == 0 { + return &agent.Result{Success: false, ErrorReason: "missing target deliverable " + target}, nil + } + return &agent.Result{Success: true, Patches: map[string][]byte{target: content}}, nil } diff --git a/internal/squad/step_retry_test.go b/internal/squad/step_retry_test.go index 7966fc4e..ba06a765 100644 --- a/internal/squad/step_retry_test.go +++ b/internal/squad/step_retry_test.go @@ -71,6 +71,11 @@ func (b *countingBackend) Execute(ctx context.Context, req agent.Request) (*agen _ = os.WriteFile(filepath.Join(req.WorkDir, "output.md"), body, 0o644) return &agent.Result{Success: false, ErrorReason: "Connection error.", Retryable: true}, nil + case "success-without-patch": + return &agent.Result{Success: true, Patches: map[string][]byte{ + "execution.log": []byte("Outcome: SUCCESS\n"), + }}, nil + default: return &agent.Result{Success: false, ErrorReason: "unknown mode"}, nil } @@ -174,14 +179,14 @@ func TestWorkflowDriver_DoesNotOverwriteExistingTarget(t *testing.T) { wsDir, driver := prepareSingleWriterWS(t) backend := &countingBackend{mode: "fail-after-writing-target"} res, err := driver.Execute(context.Background(), wsDir, backend, squad.ExecuteOptions{Timeout: 5 * time.Second}) - if err != nil { - t.Fatalf("existing target must be accepted, not retried: %v", err) - } - if !res.Success { - t.Fatalf("want success from existing file, got %s", res.ErrorReason) + if err == nil || res.Success { + t.Fatal("backend explicit failure must fail-closed; a possibly truncated file is not success") } if backend.calls != 1 { - t.Fatalf("must not re-invoke over an existing target, calls=%d", backend.calls) + t.Fatalf("must not re-invoke over this-invocation target, calls=%d", backend.calls) + } + if !strings.Contains(err.Error(), "Connection error.") { + t.Fatalf("want backend failure preserved, got %v", err) } body, err := os.ReadFile(filepath.Join(wsDir, "tasks", "01_draft", "output.md")) if err != nil { @@ -192,6 +197,29 @@ func TestWorkflowDriver_DoesNotOverwriteExistingTarget(t *testing.T) { } } +func TestWorkflowDriver_PriorResidueIsNotSuccess(t *testing.T) { + wsDir, driver := prepareSingleWriterWS(t) + leftover := []byte("# leftover from previous round\n") + if err := os.WriteFile(filepath.Join(wsDir, "tasks", "01_draft", "output.md"), leftover, 0o644); err != nil { + t.Fatal(err) + } + backend := &countingBackend{mode: "success-without-patch"} + res, err := driver.Execute(context.Background(), wsDir, backend, squad.ExecuteOptions{Timeout: 5 * time.Second}) + if err == nil || res.Success { + t.Fatal("prior-round residue must not count as this-invocation success") + } + if backend.calls != 1 { + t.Fatalf("residue without trusted patch is a permanent contract miss, calls=%d", backend.calls) + } + got, err := os.ReadFile(filepath.Join(wsDir, "tasks", "01_draft", "output.md")) + if err != nil { + t.Fatal(err) + } + if string(got) != string(leftover) { + t.Fatalf("residue must stay on disk unchanged: %s", got) + } +} + func TestWorkflowDriver_BoundedTransientRetryThenFailClosed(t *testing.T) { wsDir, driver := prepareSingleWriterWS(t) backend := &countingBackend{mode: "always-transient", failText: "Connection error."} diff --git a/internal/squad/workflow.go b/internal/squad/workflow.go index 5a279e7a..2f8e5a8a 100644 --- a/internal/squad/workflow.go +++ b/internal/squad/workflow.go @@ -1,6 +1,7 @@ package squad import ( + "bytes" "context" "encoding/json" "fmt" @@ -512,13 +513,9 @@ func (d *WorkflowDriver) runOneStep( ) for attempt := 1; attempt <= agent.MaxTransientStepInvocations; attempt++ { onDisk, _ := os.ReadFile(prevOutPath) - produced := len(strings.TrimSpace(string(onDisk))) > 0 && string(onDisk) != prevContent - if attempt > 1 && produced { - ok = true - matErr = nil - if res != nil { - res.Success = true - } + wroteThisInvocation := deliverableWroteThisInvocation(prevContent, onDisk) + if attempt > 1 && wroteThisInvocation { + // Preserve a this-invocation file (may be truncated). Do not call again. break } if attempt > 1 && streamer != nil { @@ -536,32 +533,34 @@ func (d *WorkflowDriver) runOneStep( if res != nil { incoming = res.Patches } + hasTrustedPatch := incomingHasDeliverable(subDir, deliverableName, incoming) if execErr == nil && res != nil && res.Success { if _, matErr = agent.MaterializeTargets(subDir, []string{deliverableName}, incoming); matErr != nil { res.Success = false res.ErrorReason = matErr.Error() + } else if !hasTrustedPatch { + after, _ := os.ReadFile(prevOutPath) + if !deliverableWroteThisInvocation(prevContent, after) { + matErr = fmt.Errorf("missing target deliverable %s", deliverableName) + res.Success = false + res.ErrorReason = matErr.Error() + } } } onDisk, _ = os.ReadFile(prevOutPath) - produced = len(strings.TrimSpace(string(onDisk))) > 0 && string(onDisk) != prevContent + wroteThisInvocation = deliverableWroteThisInvocation(prevContent, onDisk) newContent := "" - if produced { - newContent = string(onDisk) - } else if res != nil { + if hasTrustedPatch && res != nil { if b, has := res.Patches[deliverableName]; has { newContent = string(b) } } - if newContent == "" { + if newContent == "" && wroteThisInvocation { newContent = string(onDisk) } - ok = (execErr == nil && res != nil && res.Success && matErr == nil) || produced - if produced && res != nil { - res.Success = true - matErr = nil - } + ok = execErr == nil && res != nil && res.Success && matErr == nil sim := OutputSimilarity(prevContent, newContent) if mu != nil { mu.Lock() @@ -604,8 +603,8 @@ func (d *WorkflowDriver) runOneStep( Err: execErr, Result: res, WorkDir: subDir, - TargetMissing: !produced && (matErr != nil || (res != nil && strings.Contains(res.ErrorReason, "missing target deliverable"))), - TargetExists: produced, + TargetMissing: !wroteThisInvocation && (matErr != nil || (res != nil && strings.Contains(res.ErrorReason, "missing target deliverable"))), + TargetExists: wroteThisInvocation, }) if assess.Class != agent.FailureTransient || attempt >= agent.MaxTransientStepInvocations { break @@ -711,6 +710,25 @@ func (d *WorkflowDriver) runOneStep( return nil } +func deliverableWroteThisInvocation(baseline string, onDisk []byte) bool { + if len(bytes.TrimSpace(onDisk)) == 0 { + return false + } + return string(onDisk) != baseline +} + +func incomingHasDeliverable(workDir, target string, incoming map[string][]byte) bool { + for k, v := range incoming { + if len(bytes.TrimSpace(v)) == 0 { + continue + } + if agent.MatchDeliverableRel(workDir, k, target) { + return true + } + } + return false +} + func (d *WorkflowDriver) applyDynamicAdjustment( ctx context.Context, wsDir string, From 051e69ccd16e40485c7a361f46ce2cd140c8c9f2 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 19:25:41 +0000 Subject: [PATCH 13/13] fix(pi-agent): clear recovered SDK transients before settle auto_retry_end success=true now drops providerTransient and lastErr so a later agent_settled can succeed when the in-process retry landed a real target. A new error after recovery still fails. Co-authored-by: Cursor --- deploy/pi-agent/settle-policy.mjs | 6 +++++ deploy/pi-agent/settle-policy.test.mjs | 33 ++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/deploy/pi-agent/settle-policy.mjs b/deploy/pi-agent/settle-policy.mjs index 9a88aca3..0ac5332d 100644 --- a/deploy/pi-agent/settle-policy.mjs +++ b/deploy/pi-agent/settle-policy.mjs @@ -69,6 +69,12 @@ export function applyAgentEvent(state, ev) { state.retryFailed = true; state.providerTransient = true; state.lastErr = ev.finalError || ev.errorMessage || state.lastErr || "auto_retry_failed"; + } else if (ev.success === true) { + // SDK in-process retry recovered. Drop this-round transient so a later + // agent_settled can succeed; later events may set a new error again. + state.retryFailed = false; + state.providerTransient = false; + state.lastErr = ""; } else { state.retryFailed = false; } diff --git a/deploy/pi-agent/settle-policy.test.mjs b/deploy/pi-agent/settle-policy.test.mjs index e9aa2a7f..f03821eb 100644 --- a/deploy/pi-agent/settle-policy.test.mjs +++ b/deploy/pi-agent/settle-policy.test.mjs @@ -48,4 +48,37 @@ const promptEmpty = classifyRunOutcome({ }); assert.equal(promptEmpty.retryable, false); +const recovered = newSettleState(); +for (const ev of [ + { type: "message_end", message: { stopReason: "error", errorMessage: "terminated" } }, + { type: "auto_retry_start", attempt: 1, maxAttempts: 3, errorMessage: "terminated" }, + { type: "auto_retry_end", success: true, attempt: 1 }, + { type: "agent_settled" }, +]) { + applyAgentEvent(recovered, ev); +} +assert.equal(recovered.retryFailed, false, "recovered SDK retry must clear retryFailed"); +assert.equal(recovered.providerTransient, false, "recovered SDK retry must clear providerTransient"); +assert.equal(recovered.lastErr, "", "recovered SDK retry must clear lastErr"); +assert.equal(recovered.succeeded, true, "settle after recovered auto_retry must succeed"); +const recoveredOk = classifyRunOutcome({ + ...recovered, + missing: [], +}); +assert.equal(recoveredOk.ok, true, "settled + target present after recovered retry is success"); +assert.equal(recoveredOk.retryable, false); + +const recoveredThenNewError = newSettleState(); +for (const ev of [ + { type: "message_end", message: { stopReason: "error", errorMessage: "terminated" } }, + { type: "auto_retry_end", success: true, attempt: 1 }, + { type: "message_end", message: { stopReason: "error", errorMessage: "Connection error." } }, + { type: "agent_settled" }, +]) { + applyAgentEvent(recoveredThenNewError, ev); +} +assert.equal(recoveredThenNewError.succeeded, false, "a new error after recovery must not be masked"); +assert.equal(recoveredThenNewError.providerTransient, true); +assert.match(recoveredThenNewError.lastErr, /Connection error/i); + console.log("settle-policy.test.mjs PASS");