merge: fix/document-revision-prelim-output

This commit is contained in:
weichen
2026-09-13 19:26:50 +00:00
23 changed files with 1852 additions and 87 deletions
@@ -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,不会单独提高成功率
+4 -2
View File
@@ -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/<slug>` 或 `fix/<slug>`,**禁止** `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) |
@@ -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)
@@ -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)
+2
View File
@@ -10,6 +10,8 @@ web/dist
web/.vite
bin
*.md
!templates/**/*.md
!configs/**/*.md
.env
.env.*
!.env.example
+4
View File
@@ -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 \
+40 -10
View File
@@ -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,
+122
View File
@@ -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: "" };
}
+84
View File
@@ -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");
+3
View File
@@ -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 {
+162
View File
@@ -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")
}
}
+104
View File
@@ -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/<target>.
// 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
}
+83
View File
@@ -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")
}
}
+52 -27
View File
@@ -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{
+193
View File
@@ -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)
}
+159
View File
@@ -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)
}
}
+6 -1
View File
@@ -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
}
+236
View File
@@ -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")
}
}
+130 -44
View File
@@ -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,
+221
View File
@@ -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
}
+23 -3
View File
@@ -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)
+29
View File
@@ -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)
}
}
}
+9
View File
@@ -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