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..a3872b8b --- /dev/null +++ b/.doc/audit/2026-09-13-document-revision-prelim-output.md @@ -0,0 +1,77 @@ +--- +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` + +## 现场时序(只读 workspace / execution.log / trace) + +工作区:`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 重试耗尽 | +| 模板 | 产品前置条件 | `.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 落地,旧映射也可能认不到 | + +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` + +## 关联整改 + +- 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` +- 本轮不执行 `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 7beda461..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**;第十轮 UI-11 路由+lease;不操作容器) +**production_ready**: no(DOCREV:schema **30**;第11轮 UI-11 prelim output 契约;编码可改 Dockerfile / 测 build context,不执行 env-service 重建) ## 焦点 -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`: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 @@ -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..121557a0 --- /dev/null +++ b/.doc/ops/short-term/2026-09-13-document-revision-prelim-output.md @@ -0,0 +1,59 @@ +--- +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;对「进程 0 + settled failure / 缺产物」的**瞬时**失败做有界全新 step 重试。不得标 `done`。 + +## 范围 + +- `internal/template` embed + loader +- `internal/agent` 路径映射、目标强制、瞬时/永久分类、`Result.Retryable` +- `internal/squad` `runOneStep` 有界全新 invocation +- `deploy/pi-agent/runner.mjs` + `settle-policy.mjs` +- `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;不放宽缺失校验 +- 不触碰/提交证据目录;不执行 env-service 重建/重启;本轮不重跑 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. [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] 本次写入的目标不覆盖、不再调用;backend 明确失败仍 FAIL(不把文件存在当成功);上一轮残留不当成功 +8. [ ] 占槽后真实 UI 重跑(本轮不做) + +## 验收标准 + +- [ ] 无盘模板时 `prelim_design` 仍为官方「系统初步设计方案」 +- [ ] 绝对/`./`/重复子目录 `rel_path` 能落到目标文件 +- [ ] `ok:true` 但无非空目标 → 失败,不发明正文 +- [x] 瞬时 settled failure → 最多一次全新 step;永久缺失不重试;耗尽 fail-closed +- [ ] 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..0af3b25f --- /dev/null +++ b/.doc/remediation/2026-09-13-document-revision-prelim-output.md @@ -0,0 +1,50 @@ +--- +title: "prelim_design 真实产物契约:embed + 映射 + settle fail-closed + 瞬时全新 step 有界重试" +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)。 + +外部:MiniMax 首次 `write` 未执行(`terminated`)+ SDK 三次 `Connection error`。 +产品:`agent_settled` / 进程 0 / `trace success=true` 被当成步成功,没有全新 step 重试。 + +## 目标 + +通用、fail-closed 的产物与重试契约:成功步必须有真实、非空目标文件。瞬时 provider 失败可做有界**全新 step invocation**。永久缺失不得空转重试。不得伪造 `output.md`、不得改 E2E prompt、不得从截断 `write` 参数落盘。 + +## 分波次计划 + +| 波次 | 内容 | 验收 | +|------|------|------| +| 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) | 仅 `(provider terminated/connection + 缺目标)` 或 runner 明确失败;本次写入不覆盖且失败不翻成功;上一轮残留不当成功;耗尽 fail-closed | +| Wave 1 | 旧 runner 假成功:扫描 `execution.log` 识别 settled failure | 不依赖本轮 env-service 重建 | + +## 取舍 + +- 不从 `execution.log` 截断的 `write` 参数拼出正文。 +- 不改 UI-11 prompt。Dockerfile / dockerignore 已修并测 context;不执行 env-service 重建。 +- 产品重试是**新的** `backend.Execute` / 新 PI 进程,不是回放 SDK 半截 session,也不是执行截断 toolCall。 +- 超时(整步 timeout)不按瞬时重试,避免 2×30min。 + +## 验收标准 + +- [x] embed + 路径映射 + runner fail-closed + 单测 +- [x] dockerignore 放行官方 markdown;Docker context 可读 `/app/templates/docs/prelim_design.md` +- [x] 瞬时/永久分类 + 有界全新 step 重试 + 已有目标不覆盖 + 单测(本机门禁;真实 UI 未重跑) +- [ ] 占槽后真实 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) 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 eafd7510..3fa1d126 100644 --- a/deploy/app/Dockerfile +++ b/deploy/app/Dockerfile @@ -30,6 +30,10 @@ 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 ENV DATA_DIR=/app/data \ HTTP_HOST=0.0.0.0 \ diff --git a/deploy/pi-agent/runner.mjs b/deploy/pi-agent/runner.mjs index bc300d40..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,7 +160,7 @@ async function handleRun(body) { let agentId = ""; let runId = ""; let lastErr = ""; - let succeeded = false; + const settle = newSettleState(); console.log(`[runner] Starting pi run in workspace: ${workspace}, model: ${model}`); @@ -201,13 +202,10 @@ async function handleRun(body) { const t = ev.type || ""; if (t === "session" && ev.id) agentId = ev.id; + applyAgentEvent(settle, ev); if (t === "agent_settled") { - succeeded = true; child.stdin.end(); } - if (t === "agent_error") { - lastErr = ev.error?.message || ev.message || "agent_error"; - } } }); @@ -238,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"); @@ -248,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: [], }; @@ -261,10 +266,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 +290,23 @@ async function handleRun(body) { } catch {} } + if (missing.length > 0) { + const classified = classifyRunOutcome({ + ...settle, + lastErr, + missing, + }); + return { + ok: false, + agent_id: agentId, + run_id: runId, + retryable: classified.retryable, + fallback_reason: classified.fallback_reason, + stderr: stderrBuf ? stderrBuf.slice(0, 400) : undefined, + patches, + }; + } + return { ok: true, agent_id: agentId, diff --git a/deploy/pi-agent/settle-policy.mjs b/deploy/pi-agent/settle-policy.mjs new file mode 100644 index 00000000..0ac5332d --- /dev/null +++ b/deploy/pi-agent/settle-policy.mjs @@ -0,0 +1,122 @@ +/** + * 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 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; + } + } + 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..f03821eb --- /dev/null +++ b/deploy/pi-agent/settle-policy.test.mjs @@ -0,0 +1,84 @@ +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); + +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"); 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 ffaced36..5d9a2cb3 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,164 @@ 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) +} + +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/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..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,19 +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 := 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 { + res := &Result{ + Success: false, + ErrorReason: err.Error(), + Patches: patches, + Logs: stdout.String(), } + res.Retryable = AssessStepFailure(StepFailureSignals{ + Result: res, + WorkDir: workDir, + TargetMissing: true, + }).Class == FailureTransient + return res, nil } return &Result{ @@ -148,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() @@ -156,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"` @@ -176,27 +194,34 @@ 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 - } - - patches := make(map[string][]byte) - 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) - } - - 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 - } + 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)) + for _, pt := range remoteResp.Patches { + incoming[pt.RelPath] = []byte(pt.Content) + } + patches, matErr := MaterializeTargets(workDir, req.Targets, incoming) + if matErr != nil { + res := &Result{ + Success: false, + ErrorReason: matErr.Error(), + Patches: patches, + } + 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..fab26348 --- /dev/null +++ b/internal/agent/step_retry.go @@ -0,0 +1,193 @@ +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 + // 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 +} + +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 sig.TargetExists { + return FailureAssessment{Class: FailurePermanent, Reason: "target-already-present: do not overwrite"} + } + if isPermanentReason(lower) { + return FailureAssessment{Class: FailurePermanent, Reason: "permanent: " + 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 provider && missing { + kind := "provider-transient-missing-target" + if trace.Settled { + kind = "settled-failure-missing-target" + } + return FailureAssessment{Class: FailureTransient, Reason: kind + ": " + 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 hasProviderTermOrConn(lower string) bool { + return strings.Contains(lower, "connection error") || + strings.Contains(lower, "terminated") || + strings.Contains(lower, "econnreset") || + 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 { + 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..853f16bf --- /dev/null +++ b/internal/agent/step_retry_test.go @@ -0,0 +1,159 @@ +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, + }, + { + 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) { + 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) + } +} 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 new file mode 100644 index 00000000..ba06a765 --- /dev/null +++ b/internal/squad/step_retry_test.go @@ -0,0 +1,236 @@ +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 + + 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 + + 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 + } +} + +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_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 || 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 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 { + t.Fatal(err) + } + if string(body) != "# already on disk\n\ndo not overwrite\n" { + t.Fatalf("target was rewritten: %s", body) + } +} + +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."} + 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 a38a379c..2f8e5a8a 100644 --- a/internal/squad/workflow.go +++ b/internal/squad/workflow.go @@ -1,6 +1,7 @@ package squad import ( + "bytes" "context" "encoding/json" "fmt" @@ -503,52 +504,116 @@ 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++ { + onDisk, _ := os.ReadFile(prevOutPath) + 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 { + 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) + 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() + } + } } - } - 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 { + + onDisk, _ = os.ReadFile(prevOutPath) + wroteThisInvocation = deliverableWroteThisInvocation(prevContent, onDisk) + newContent := "" + if hasTrustedPatch && res != nil { + if b, has := res.Patches[deliverableName]; has { + newContent = string(b) + } + } + if newContent == "" && wroteThisInvocation { + newContent = string(onDisk) + } + 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: !wroteThisInvocation && (matErr != nil || (res != nil && strings.Contains(res.ErrorReason, "missing target deliverable"))), + TargetExists: wroteThisInvocation, + }) + 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 +623,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,17 +648,19 @@ 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) + 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 fieldsPath := filepath.Join(subDir, "fields.json") if fData, err := os.ReadFile(fieldsPath); err == nil { @@ -643,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, 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 0eed3ed8..1b93e5d6 100644 --- a/internal/template/loader.go +++ b/internal/template/loader.go @@ -5,26 +5,46 @@ import ( "os" "path/filepath" "strings" + + "chat-agent-system/templates" ) 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 } 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