merge: fix/document-revision-prelim-output
This commit is contained in:
@@ -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
@@ -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)
|
||||
@@ -10,6 +10,8 @@ web/dist
|
||||
web/.vite
|
||||
bin
|
||||
*.md
|
||||
!templates/**/*.md
|
||||
!configs/**/*.md
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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: "" };
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user