fix(squad): keep quality repair inside a local snapshot workspace

Repair TASK.md told the model to edit ../../output.md, so the local
target never appeared and UI-03 stayed quality_failed. Snapshot parent
inputs locally, restore them on containment violations, and promote
only a trusted repair/output.md before revalidate.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 20:15:54 +00:00
co-authored by Cursor
parent 8b1a531947
commit b7cfb28217
13 changed files with 873 additions and 52 deletions
@@ -0,0 +1,54 @@
---
title: "UI-03 质量修复子工作区越界改写根 output.md"
status: in_progress
created: 2026-09-13
updated: 2026-09-13
severity: high
---
# UI-03 质量修复子工作区越界改写根 output.md
## 现象
第13轮 Antigravity 完整 UI(schema 30,dev@8b1a531 基线):UI00–02 PASS,UI-03 真实 PI 终态 `quality_failed`,页面 70 分、缺少必填章节「阻塞信息 / 非阻塞信息」。
真实工作区 `/app/data/workspaces/bizdev/intake_1789329815528283509`:
- `tasks/03_review/output.md` 于 20:07:02 **确实没有**两个章节;质量门禁判定正确,不得放宽必填章节。
- `EnsureQualityOrRepair` 创建 `tasks/repair_1`,TASK.md 同时写「参考 `../../template.md`、基于 `../../output.md`、输出到 `output.md`」。
- 真实模型读取父级文件后,`edit` 写了 `tasks/repair_1/../../output.md`(根 `output.md`);根文件 20:07:24 已补齐两章节。
- `tasks/repair_1/output.md` **从未生成**;runner 目标仍是本地 `output.md` → `missing target` → `runOneStep` 失败 → `EnsureQualityOrRepair` break → manifest 保留原 70 分。
- runner `execution.log` 的 `Outcome: SUCCESS` 只是模型 settle,不代表目标契约成功。
## 根因分析
| 层 | 问题 | 后果 |
|---|---|---|
| TASK 契约 | 修复步骤把输入写成 `../../output.md` / `../../template.md` | 模型按字面去 edit 父级 |
| 沙箱 | `buildSubWorkspaces` 对 template/blackboard 建 **symlink** | 子目录写同名文件会打穿父级 |
| Needs | 只在 TASK 里列 `../../need`,不把原稿快照成非目标文件名 | 若复制成本地 `output.md` 会假成功;当前则诱导越界 |
| 成功判定 | 只认本地 `output.md`(正确),但失败后不恢复被改的根文件 | 根文件「看起来更好」却不覆盖/不重验,页面仍 70 |
| settle | 内部 Outcome SUCCESS ≠ 本地目标存在 | 不得把 settle 当交付 |
`MatchDeliverableRel` 已拒绝 `../`,因此 missing-target fail-closed 正确,**不能**因此把越界根文件当成功。
## 严重度
`high`
自动修复轮空转:模型可能已经写好章节,契约失败导致任务 `quality_failed`。
## 关联整改
- Remediation: [../remediation/2026-09-13-document-revision-quality-repair-workspace-contract.md](../remediation/2026-09-13-document-revision-quality-repair-workspace-contract.md)
- Short-term: [../ops/short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md](../ops/short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md)
- 分支: `fix/document-revision-quality-repair-workspace-contract`
## 证据
- 失败证据(根代理保留、本轮不得改/移/删/提交):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui03-required-sections-quality-failure/`
## 开放问题
- 本轮不跑真实 UI-03;不得标 E2E 通过或任务 `done`
- 默认子步骤仍可用 `../../` + symlink(未改正常步骤读本地 context 的路径)
+8
View File
@@ -31,6 +31,14 @@
### 最新专项审计(2026-09-13)
**文件**: [2026-09-13-document-revision-quality-repair-workspace-contract.md](./2026-09-13-document-revision-quality-repair-workspace-contract.md)
**结论**: 第13轮 UI-03 质量门禁正确判缺「阻塞信息/非阻塞信息」;repair TASK 引导 `../../output.md`,模型越界改根文件,本地目标缺失,manifest 停在 70。
**整改**: [质量修复子工作区契约整改](../remediation/2026-09-13-document-revision-quality-repair-workspace-contract.md)
### 编辑弹窗 hydration 竞态(2026-09-13)
**文件**: [2026-09-13-document-revision-editor-hydration-race.md](./2026-09-13-document-revision-editor-hydration-race.md)
**结论**: 第十二轮 UI-06 在 fill 窗口完成后台 hydration,块 ID 被重建;保存按旧 ID 找不到目标后静默关窗,未发 PATCH,修订停在 r0。
+5 -3
View File
@@ -1,12 +1,12 @@
# 当前焦点
**更新**: 2026-09-13
**production_ready**: no(DOCREV:schema **30**;第十二轮 UI-06 编辑弹窗 hydration 竞态;不执行 env-service 重建 / 不声称 E2E 通过)
**production_ready**: no(DOCREV:schema **30**;第13轮 UI-03 质量修复子工作区契约;不执行 env-service 重建 / 不声称 E2E 通过)
## 焦点
1. **DOCREV-RT**: [编辑弹窗 hydration 竞态](short-term/2026-09-13-document-revision-editor-hydration-race.md)(`fix/document-revision-editor-hydration-race`;第十二轮 UI-06 r0 静默丢稿;不声称 E2E 通过)
2. **DOCREV-RT**: [prelim_design 缺 output.md 产物契约修复](short-term/2026-09-13-document-revision-prelim-output.md)(`fix/document-revision-pi-runner-module` 已合入 `dev@4c465d2`;仍 `in_progress`,待占槽重跑 UI-11)
1. **DOCREV-RT**: [质量修复子工作区契约](short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md)(`fix/document-revision-quality-repair-workspace-contract`;第13轮 UI-03 越界改根 output.md;不声称 E2E 通过)
2. **DOCREV-RT**: [编辑弹窗 hydration 竞态](short-term/2026-09-13-document-revision-editor-hydration-race.md)(已合入 `dev@8b1a531`;仍 `in_progress`,待占槽重跑 UI-06)
3. **CHAT**: [刷新恢复整改已实施](short-term/2026-09-13-chat-history-refresh-remediation.md)(待合 dev + PI E2E 补录)
## 并行认领
@@ -29,6 +29,7 @@
| 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-pi-runner-module | - | document-revision-prelim-output | in_progress |
| Cursor-Auto | document-revision-editor-hydration-race | fix/document-revision-editor-hydration-race | - | document-revision-editor-hydration-race | in_progress |
| Cursor-Auto | document-revision-quality-repair-workspace-contract | fix/document-revision-quality-repair-workspace-contract | - | document-revision-quality-repair-workspace-contract | in_progress |
> 新任务开始前登记。`branch` 必须为 `feature/<slug>` 或 `fix/<slug>`,**禁止** `dev` / `test` / `main`。同一 `mutex` 同时仅一个 `in_progress`。
@@ -75,3 +76,4 @@
| 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) |
| UI-06 编辑弹窗 hydration 竞态审计 | [../audit/2026-09-13-document-revision-editor-hydration-race.md](../audit/2026-09-13-document-revision-editor-hydration-race.md) |
| UI-03 质量修复子工作区契约审计 | [../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md](../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md) |
@@ -0,0 +1,53 @@
---
title: "质量修复子工作区契约"
status: in_progress
created: 2026-09-13
updated: 2026-09-13
owner: Cursor-Auto
branch: fix/document-revision-quality-repair-workspace-contract
worktree: "-"
mutex: document-revision-quality-repair-workspace-contract
---
# 质量修复子工作区契约
## 目标
堵住第13轮 UI-03:repair 步 TASK 引导 `../../output.md`,模型越界改根文件,本地目标缺失导致 `quality_failed` 停在 70。门禁判定保持,修复契约改为本地快照 + containment。
## 范围
- `internal/squad/repair_workspace.go` 与单测
- `EnsureQualityOrRepair` / `buildSubWorkspaces` snapshot 布局
- audit / remediation / CURRENT
## 非目标
- 不放宽必填章节;不把越界根文件当成功;不隐藏 `quality_failed`
- 不改默认步骤的 `../../` context 导航
- 不碰证据目录;不合 dev;不 rebuild Docker;不 E2E;不 push
## 步骤
1. [x] 从 `dev@8b1a531` 开分支并登记 mutex
2. [x] 快照契约 + containment + 测试
3. [x] gofmt / vet / `go test -short ./...` / `-race` squad+agent / settle-policy.test.mjs
4. [ ] 真实 UI-03(本轮不做)
## 验收标准
- [x] 本地可信产物才覆盖根并重验
- [x] 越界无本地目标则恢复父级并 fail-closed
- [ ] 现场 UI-03(未跑)
## 相关链接
- Audit: [../../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md](../../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md)
- Remediation: [../../remediation/2026-09-13-document-revision-quality-repair-workspace-contract.md](../../remediation/2026-09-13-document-revision-quality-repair-workspace-contract.md)
## 证据 / 备注
- mutex:`document-revision-quality-repair-workspace-contract`(新 mutex,不与现有 in_progress 冲突)
- 基线:`dev@8b1a531`
- 验证:`go test -short ./...` PASS;`go test -short -race ./internal/squad/ ./internal/agent/` PASS;`settle-policy.test.mjs` PASS
- **未跑真实 UI-03,不得标 done**
@@ -0,0 +1,51 @@
---
title: "质量修复子工作区输入输出契约整改"
status: in_progress
created: 2026-09-13
updated: 2026-09-13
---
# 质量修复子工作区输入输出契约整改
## 背景
链到 [../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md](../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md)。
不得放宽/删除必填章节;不得把越界改写的根 `output.md` 当成功;不得隐藏 `quality_failed`。
## 目标
- 修复步只消费本地快照、只写当前目录 `output.md`
- 越界改父级且无可信本地目标:恢复运行前字节并 fail-closed
- 仅当本地 `repair/output.md` 非空且可信时覆盖根文件并重新 Validate
- 重复/预算语义不回归;runner missing-target 仍 fail-closed
## 分波次计划
| 波次 | 内容 | 验收 |
|------|------|------|
| Wave 0 | `InputLayout=snapshot` + containment + 单测 | 本地补齐→覆盖→PASS;越界恢复→FAIL;TASK 无 `../` |
| Wave 1 | 占槽后真实 UI-03(本轮不做) | 现场不再因 repair 越界停在 70 |
## 设计
1. **快照而非 `../../`**:`AddStep` 对 repair 使用 `InputLayoutSnapshot`。把 `output.md` 复制为 `source_output.md`(绝不占目标槽),`template.md` / `blackboard.json` 复制为常规文件(去 symlink)。
2. **TASK.md**:质量说明只引用本地名,禁止 `../`。
3. **运行前快照**:根 `output.md` / `template.md` / `blackboard.json` 字节。
4. **containment**:父级任一输入被改 → 先恢复运行前字节并写 `gov_audit.jsonl`。无可信本地目标则 break(保持原报告)。父级与本地同时改:恢复父级后**只提升本地**目标。
5. **成功路径**:`TrustedLocalDeliverable`(常规文件、非空、不逃逸)→ 覆盖根 `output.md` → `Validate`。
6. 默认步骤仍走原 `../../` + symlink,避免影响正常子任务读 context。
## 验收标准
- [x] 原稿缺章节 → 本地修复产物补齐 → 根覆盖 → revalidate PASS
- [x] 只越界改父级、缺本地目标 → 父级恢复、FAIL + containment 审计
- [x] 父级与本地同时改:恢复父级、提升本地
- [x] TASK.md / 工作区构造不含 `../` 输入引用
- [x] 修复预算仍消耗一轮;missing-target fail-closed
- [ ] 真实 UI-03(本轮不做)
## 相关链接
- Audit / Short-term:同目录 2026-09-13-document-revision-quality-repair-workspace-contract
- 证据(只读):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui03-required-sections-quality-failure/`
+1
View File
@@ -3,6 +3,7 @@
| 文档 | 说明 |
|------|------|
| [gap-matrix.md](./gap-matrix.md) | 发布缺口 SSOT |
| [2026-09-13-document-revision-quality-repair-workspace-contract.md](./2026-09-13-document-revision-quality-repair-workspace-contract.md) | 质量修复子工作区越界改根 output.md,本地目标缺失 |
| [2026-09-13-document-revision-editor-hydration-race.md](./2026-09-13-document-revision-editor-hydration-race.md) | 编辑弹窗打开期间 hydration 换块 ID,保存静默丢稿 |
| [2026-09-13-document-revision-runtime-remediation.md](./2026-09-13-document-revision-runtime-remediation.md) | 账本 PG 持久化、bootstrap、保存/overlay/确定性 change report |
| [2026-09-13-document-revision-management-implementation-plan.md](./2026-09-13-document-revision-management-implementation-plan.md) | 智能体版本、人工修订、差异认知与负责人通知 |
+1
View File
@@ -20,6 +20,7 @@ func TestMatchDeliverableRel_Aliases(t *testing.T) {
{"tasks/01_draft/output.md", true},
{"execution.log", false},
{"../output.md", false},
{"../../output.md", false},
{"/etc/passwd", false},
{"tasks/02_review/output.md", false},
}
+16 -14
View File
@@ -63,13 +63,15 @@ const (
// StepSpec 单个任务步骤规格定义
type StepSpec struct {
ID string `json:"id"`
Role string `json:"role"`
Title string `json:"title"`
SubWorkspace string `json:"sub_workspace"` // 相对子路径,如 tasks/01_organize
Mode string `json:"mode"` // execute, plan, review_plan, replan
Parallel bool `json:"parallel"`
Needs []string `json:"needs,omitempty"`
ID string `json:"id"`
Role string `json:"role"`
Title string `json:"title"`
SubWorkspace string `json:"sub_workspace"` // 相对子路径,如 tasks/01_organize
Mode string `json:"mode"` // execute, plan, review_plan, replan
Parallel bool `json:"parallel"`
Needs []string `json:"needs,omitempty"`
// InputLayoutSnapshot 将父级输入复制为子目录常规文件,TASK.md 只引用本地名。
InputLayout string `json:"input_layout,omitempty"`
Accept []string `json:"accept,omitempty"`
Status string `json:"status"` // pending, running, completed, failed, skipped
DurationMs int64 `json:"duration_ms,omitempty"`
@@ -240,12 +242,12 @@ type AdvisorCaseRecommendation struct {
// AdvisorBriefing B轨业务参谋决策锦囊
type AdvisorBriefing struct {
MaturityScore int `json:"maturity_score"` // 成熟度评分 (0-100)
MaturityLevel string `json:"maturity_level"` // 成熟度等级 (如 V0.1, V0.5, V1.0)
MaturityHeadline string `json:"maturity_headline"` // 总体进展研判(一句话核心诊断)
BaselineAssumptions []string `json:"baseline_assumptions"` // 基准假设清单(替代填空,为用户预设)
PreemptiveRisks []string `json:"preemptive_risks"` // 预见性风险雷达(替用户提前看两步)
NextBestActions []string `json:"next_best_actions"` // 建议下一步行动选项
CommunicationKit string `json:"communication_kit"` // 建议对外沟通话术(微信/邮件模板)
MaturityScore int `json:"maturity_score"` // 成熟度评分 (0-100)
MaturityLevel string `json:"maturity_level"` // 成熟度等级 (如 V0.1, V0.5, V1.0)
MaturityHeadline string `json:"maturity_headline"` // 总体进展研判(一句话核心诊断)
BaselineAssumptions []string `json:"baseline_assumptions"` // 基准假设清单(替代填空,为用户预设)
PreemptiveRisks []string `json:"preemptive_risks"` // 预见性风险雷达(替用户提前看两步)
NextBestActions []string `json:"next_best_actions"` // 建议下一步行动选项
CommunicationKit string `json:"communication_kit"` // 建议对外沟通话术(微信/邮件模板)
RecommendedCases []AdvisorCaseRecommendation `json:"recommended_cases,omitempty"` // 💡 往期标杆案例与历史报价经验自推荐
}
+43 -35
View File
@@ -600,57 +600,65 @@ func (q *QualityGate) EnsureQualityOrRepair(
SubWorkspace: subDir,
Mode: "execute",
Needs: []string{"output.md"},
InputLayout: InputLayoutSnapshot,
Accept: acceptCriteria,
}
parentSnap := SnapshotWorkspaceInputs(wsDir)
if err := driver.AddStep(wsDir, repairStep); err != nil {
return report, fmt.Errorf("add repair step: %w", err)
}
// 定制该修复子任务的 TASK.md 增加门禁报错说明
repairTaskPath := filepath.Join(wsDir, filepath.FromSlash(subDir), "TASK.md")
var b strings.Builder
b.WriteString(fmt.Sprintf("# 质量门禁修复任务 (第 %d 次修正)\n\n", attempt))
b.WriteString("前序提交未通过自动化质量门禁,检测到的问题如下,请针对性修复:\n")
for _, v := range report.Violations {
b.WriteString(fmt.Sprintf("- ❌ %s\n", sanitizeViolationForAgent(v)))
repairAbs := filepath.Join(wsDir, filepath.FromSlash(subDir))
if err := WriteRepairTaskMarkdown(repairAbs, attempt, report, stallHinted); err != nil {
return report, fmt.Errorf("write repair TASK.md: %w", err)
}
// 治理:停滞升级——上轮修复产物与再上轮几乎相同,注入反重复指令换策略
if stallHinted {
b.WriteString("\n⚠️ 治理警告:最近两轮修复产物相似度过高,被判定为无进度空转。本轮必须针对上述违规点逐条重写对应章节,产出与之前实质不同的内容,否则任务将被强制终止。\n")
}
b.WriteString("\n请参考 `../../template.md`,并基于根目录 `../../output.md`(当前未通过门禁的初稿)与黑板事实,补全并展开所有章节,输出完整的修复初稿到 `output.md`。\n")
_ = os.WriteFile(repairTaskPath, []byte(b.String()), 0o644)
// 执行修复步骤(治理入账:轨迹 + 预算)
runErr := driver.runOneStep(ctx, wsDir, repairStep, backend, opts.Timeout, opts.StepNotify, nil, &[]string{}, ledger)
if runErr != nil {
repairedBytes, localOK := TrustedLocalDeliverable(repairAbs, RepairTargetName)
mutated := ParentInputsMutated(wsDir, parentSnap)
if len(mutated) > 0 {
_ = RestoreWorkspaceInputs(wsDir, parentSnap)
recordContainmentViolation(wsDir, attempt, mutated, localOK, parentSnap)
if st := stream.FromContext(ctx); st != nil {
st.EmitPhase("governance", "修复 containment 违规: 父级输入已被越界改写,已恢复运行前字节", "taskgov", 0, 0, 0)
}
}
if !localOK {
if runErr != nil {
appendGovAudit(wsDir, "repair_local_target_missing", map[string]string{
"attempt": fmt.Sprintf("%d", attempt),
"error": runErr.Error(),
})
}
break
}
// 读出修复后的产物并覆盖根目录 output.md
repairedOutPath := filepath.Join(wsDir, filepath.FromSlash(subDir), "output.md")
if repairedBytes, err := os.ReadFile(repairedOutPath); err == nil && len(repairedBytes) > 0 {
// 治理:跨修复轮停滞检测(相似度 ≥ 0.98 判定无进度;二次停滞直接止损)
sim := OutputSimilarity(prevRepairContent, string(repairedBytes))
if sim >= 0.98 {
if stallHinted {
if st := stream.FromContext(ctx); st != nil {
st.EmitPhase("governance", fmt.Sprintf("修复停滞止损: 连续两轮修复产物相似度 %.1f%%,终止机械重试", sim*100), "taskgov", 0, 0, 0)
}
appendGovAudit(wsDir, "repair_stall_break", map[string]string{"attempt": fmt.Sprintf("%d", attempt), "similarity": fmt.Sprintf("%.3f", sim)})
stallBreak = true
} else {
stallHinted = true
appendGovAudit(wsDir, "repair_stall_hinted", map[string]string{"attempt": fmt.Sprintf("%d", attempt), "similarity": fmt.Sprintf("%.3f", sim)})
// 仅当本地 repair/output.md 非空且可信时覆盖根 output.md,再重新 Validate。
sim := OutputSimilarity(prevRepairContent, string(repairedBytes))
if sim >= 0.98 {
if stallHinted {
if st := stream.FromContext(ctx); st != nil {
st.EmitPhase("governance", fmt.Sprintf("修复停滞止损: 连续两轮修复产物相似度 %.1f%%,终止机械重试", sim*100), "taskgov", 0, 0, 0)
}
appendGovAudit(wsDir, "repair_stall_break", map[string]string{"attempt": fmt.Sprintf("%d", attempt), "similarity": fmt.Sprintf("%.3f", sim)})
stallBreak = true
} else {
stallHinted = true
appendGovAudit(wsDir, "repair_stall_hinted", map[string]string{"attempt": fmt.Sprintf("%d", attempt), "similarity": fmt.Sprintf("%.3f", sim)})
}
prevRepairContent = string(repairedBytes)
_ = os.WriteFile(outPath, repairedBytes, 0o644)
report, _ = q.Validate(wsDir, taskKey, string(repairedBytes))
if report.Passed || stallBreak {
break
}
}
prevRepairContent = string(repairedBytes)
if err := writeRegularFile(outPath, repairedBytes); err != nil {
return report, fmt.Errorf("promote repair output: %w", err)
}
report, _ = q.Validate(wsDir, taskKey, string(repairedBytes))
if report.Passed || stallBreak {
break
}
}
_ = ledger.Save(wsDir)
@@ -0,0 +1,226 @@
package squad_test
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"chat-agent-system/internal/agent"
"chat-agent-system/internal/squad"
)
const repairTemplate = "# 用户初始需求收集表\n\n## 1. 结论\n\n## 2. 详细方案\n"
const repairBadDraft = "# 用户初始需求收集表\n\n## 1. 结论\n由于篇幅有限,不做展开。\n"
const repairGoodDraft = `# 用户初始需求收集表
## 1. 结论
本项目面向专业医疗机构,采集与供电方案已按材料展开。
## 2. 详细方案
覆盖信号采集、电磁兼容与供电冗余,并标注出处〔材料: 需求表 §2〕。
`
func prepareRepairWS(t *testing.T) string {
t.Helper()
engine := squad.NewWorkspaceEngine()
ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{
WorkDir: t.TempDir(),
TaskKey: "intake",
StageTitle: "用户初始需求收集表",
TemplateContent: repairTemplate,
})
if err != nil {
t.Fatalf("PrepareWorkspace: %v", err)
}
if err := os.WriteFile(filepath.Join(ws.WorkDir, "output.md"), []byte(repairBadDraft), 0o644); err != nil {
t.Fatal(err)
}
return ws.WorkDir
}
type localRepairBackend struct{ body string }
func (b *localRepairBackend) Name() string { return "local-repair" }
func (b *localRepairBackend) Execute(_ context.Context, req agent.Request) (*agent.Result, error) {
return &agent.Result{Success: true, Patches: map[string][]byte{"output.md": []byte(b.body)}}, nil
}
type parentEscapeBackend struct{ body string }
func (b *parentEscapeBackend) Name() string { return "parent-escape" }
func (b *parentEscapeBackend) Execute(_ context.Context, req agent.Request) (*agent.Result, error) {
parent := filepath.Clean(filepath.Join(req.WorkDir, "..", "..", "output.md"))
_ = os.WriteFile(parent, []byte(b.body), 0o644)
return &agent.Result{
Success: true,
Patches: map[string][]byte{"../../output.md": []byte(b.body)},
}, nil
}
type bothWriteBackend struct{ local, parent string }
func (b *bothWriteBackend) Name() string { return "both-write" }
func (b *bothWriteBackend) Execute(_ context.Context, req agent.Request) (*agent.Result, error) {
parent := filepath.Clean(filepath.Join(req.WorkDir, "..", "..", "output.md"))
_ = os.WriteFile(parent, []byte(b.parent), 0o644)
return &agent.Result{
Success: true,
Patches: map[string][]byte{"output.md": []byte(b.local)},
}, nil
}
func TestEnsureQualityOrRepair_LocalTargetPromotesAndRevalidates(t *testing.T) {
workDir := prepareRepairWS(t)
driver := squad.NewWorkflowDriver(squad.NewWorkspaceEngine())
gate := squad.NewQualityGate()
report, err := gate.EnsureQualityOrRepair(context.Background(), workDir, "intake", driver, &localRepairBackend{body: repairGoodDraft}, 1, squad.ExecuteOptions{
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("EnsureQualityOrRepair: %v", err)
}
if report == nil || !report.Passed {
t.Fatalf("expected revalidate PASS after local repair, got %+v", report)
}
root, err := os.ReadFile(filepath.Join(workDir, "output.md"))
if err != nil || !strings.Contains(string(root), "详细方案") {
t.Fatalf("root output.md must be overwritten from local repair, got %q", root)
}
task, err := os.ReadFile(filepath.Join(workDir, "tasks", "repair_1", "TASK.md"))
if err != nil {
t.Fatal(err)
}
if squad.TaskMarkdownEscapesParent(string(task)) {
t.Fatalf("repair TASK.md must not contain ../ :\n%s", task)
}
if !strings.Contains(string(task), "source_output.md") {
t.Fatalf("repair TASK.md must cite local source_output.md:\n%s", task)
}
src, err := os.ReadFile(filepath.Join(workDir, "tasks", "repair_1", squad.RepairSourceOutputName))
if err != nil || !strings.Contains(string(src), "不做展开") {
t.Fatalf("source_output.md should keep the failing draft, got %q", src)
}
}
func TestEnsureQualityOrRepair_ParentEscapeRestoresAndFailsClosed(t *testing.T) {
workDir := prepareRepairWS(t)
orig, _ := os.ReadFile(filepath.Join(workDir, "output.md"))
driver := squad.NewWorkflowDriver(squad.NewWorkspaceEngine())
gate := squad.NewQualityGate()
report, err := gate.EnsureQualityOrRepair(context.Background(), workDir, "intake", driver, &parentEscapeBackend{body: repairGoodDraft}, 1, squad.ExecuteOptions{
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("EnsureQualityOrRepair: %v", err)
}
if report == nil || report.Passed {
t.Fatalf("escaped parent write must not count as success: %+v", report)
}
if len(report.MissingSections) == 0 {
t.Fatalf("must keep original quality_failed missing sections, got %+v", report)
}
got, _ := os.ReadFile(filepath.Join(workDir, "output.md"))
if string(got) != string(orig) {
t.Fatalf("parent output.md must be restored to pre-repair bytes, got %q", got)
}
if _, err := os.Stat(filepath.Join(workDir, "tasks", "repair_1", "output.md")); err == nil {
t.Fatal("must not invent a local repair target")
}
audit, _ := os.ReadFile(filepath.Join(workDir, "gov_audit.jsonl"))
if !strings.Contains(string(audit), squad.GovAuditContainment) {
t.Fatalf("expected containment audit, got %s", audit)
}
}
func TestEnsureQualityOrRepair_BothChangedPromotesLocalAfterRestore(t *testing.T) {
workDir := prepareRepairWS(t)
driver := squad.NewWorkflowDriver(squad.NewWorkspaceEngine())
gate := squad.NewQualityGate()
parentDecoy := repairGoodDraft + "\n\n## 越界父级不应被采用\n"
report, err := gate.EnsureQualityOrRepair(context.Background(), workDir, "intake", driver, &bothWriteBackend{
local: repairGoodDraft,
parent: parentDecoy,
}, 1, squad.ExecuteOptions{Timeout: 5 * time.Second})
if err != nil {
t.Fatalf("EnsureQualityOrRepair: %v", err)
}
if report == nil || !report.Passed {
t.Fatalf("trusted local target should still pass after restoring parent: %+v", report)
}
root, _ := os.ReadFile(filepath.Join(workDir, "output.md"))
if strings.Contains(string(root), "越界父级不应被采用") {
t.Fatalf("must not keep escaped parent bytes: %s", root)
}
if !strings.Contains(string(root), "详细方案") {
t.Fatalf("must promote local repair body, got %s", root)
}
audit, _ := os.ReadFile(filepath.Join(workDir, "gov_audit.jsonl"))
if !strings.Contains(string(audit), squad.GovAuditContainment) {
t.Fatalf("both-changed must still record containment, got %s", audit)
}
}
func TestEnsureQualityOrRepair_RepairBudgetNotRegressed(t *testing.T) {
workDir := prepareRepairWS(t)
driver := squad.NewWorkflowDriver(squad.NewWorkspaceEngine())
gate := squad.NewQualityGate()
_, err := gate.EnsureQualityOrRepair(context.Background(), workDir, "intake", driver, &parentEscapeBackend{body: repairGoodDraft}, 1, squad.ExecuteOptions{
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatal(err)
}
ledger := squad.LoadOrCreateLedger(workDir, squad.DefaultBudgetPolicy())
if ledger.RepairRounds != 1 {
t.Fatalf("containment fail still consumes one repair round, got %d", ledger.RepairRounds)
}
}
func TestAddStep_SnapshotLayoutCopiesNeedsLocally(t *testing.T) {
engine := squad.NewWorkspaceEngine()
ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{
WorkDir: t.TempDir(),
TaskKey: "intake",
StageTitle: "用户初始需求收集表",
TemplateContent: repairTemplate,
})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(ws.WorkDir, "output.md"), []byte(repairBadDraft), 0o644); err != nil {
t.Fatal(err)
}
driver := squad.NewWorkflowDriver(engine)
err = driver.AddStep(ws.WorkDir, squad.StepSpec{
ID: "step_repair_1",
Role: "writer",
Title: "repair",
SubWorkspace: "tasks/repair_1",
Mode: "execute",
Needs: []string{"output.md"},
InputLayout: squad.InputLayoutSnapshot,
})
if err != nil {
t.Fatal(err)
}
sub := filepath.Join(ws.WorkDir, "tasks", "repair_1")
task, _ := os.ReadFile(filepath.Join(sub, "TASK.md"))
if squad.TaskMarkdownEscapesParent(string(task)) {
t.Fatalf("AddStep snapshot TASK.md has ../:\n%s", task)
}
if _, err := os.Lstat(filepath.Join(sub, "template.md")); err != nil {
t.Fatal(err)
}
info, err := os.Lstat(filepath.Join(sub, "template.md"))
if err != nil || !info.Mode().IsRegular() {
t.Fatalf("snapshot template.md must be a regular file: %v", err)
}
if _, err := os.Stat(filepath.Join(sub, squad.RepairSourceOutputName)); err != nil {
t.Fatal(err)
}
}
+270
View File
@@ -0,0 +1,270 @@
package squad
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
InputLayoutSnapshot = "snapshot"
RepairSourceOutputName = "source_output.md"
RepairTargetName = "output.md"
GovAuditContainment = "repair_containment_violation"
)
// WorkspaceInputSnapshot is a byte-accurate copy of authoritative parent inputs
// taken before a contained repair step runs.
type WorkspaceInputSnapshot struct {
Output []byte
Template []byte
Blackboard []byte
OutputOK bool
TemplateOK bool
BlackboardOK bool
}
func digestBytes(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:8])
}
func SnapshotWorkspaceInputs(wsDir string) WorkspaceInputSnapshot {
var snap WorkspaceInputSnapshot
if b, err := os.ReadFile(filepath.Join(wsDir, "output.md")); err == nil {
snap.Output, snap.OutputOK = append([]byte(nil), b...), true
}
if b, err := os.ReadFile(filepath.Join(wsDir, "template.md")); err == nil {
snap.Template, snap.TemplateOK = append([]byte(nil), b...), true
}
if b, err := os.ReadFile(filepath.Join(wsDir, "blackboard.json")); err == nil {
snap.Blackboard, snap.BlackboardOK = append([]byte(nil), b...), true
}
return snap
}
func RestoreWorkspaceInputs(wsDir string, snap WorkspaceInputSnapshot) error {
if snap.OutputOK {
if err := writeRegularFile(filepath.Join(wsDir, "output.md"), snap.Output); err != nil {
return err
}
}
if snap.TemplateOK {
if err := writeRegularFile(filepath.Join(wsDir, "template.md"), snap.Template); err != nil {
return err
}
}
if snap.BlackboardOK {
if err := writeRegularFile(filepath.Join(wsDir, "blackboard.json"), snap.Blackboard); err != nil {
return err
}
}
return nil
}
func ParentInputsMutated(wsDir string, snap WorkspaceInputSnapshot) []string {
var changed []string
if snap.OutputOK {
cur, err := os.ReadFile(filepath.Join(wsDir, "output.md"))
if err != nil || !bytes.Equal(cur, snap.Output) {
changed = append(changed, "output.md")
}
}
if snap.TemplateOK {
cur, err := os.ReadFile(filepath.Join(wsDir, "template.md"))
if err != nil || !bytes.Equal(cur, snap.Template) {
changed = append(changed, "template.md")
}
}
if snap.BlackboardOK {
cur, err := os.ReadFile(filepath.Join(wsDir, "blackboard.json"))
if err != nil || !bytes.Equal(cur, snap.Blackboard) {
changed = append(changed, "blackboard.json")
}
}
return changed
}
func writeRegularFile(dst string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
_ = os.Remove(dst)
return os.WriteFile(dst, data, 0o644)
}
func copyRegularFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return writeRegularFile(dst, data)
}
// LocalNeedSnapshotName maps a parent Need onto a local filename.
// Root/step output.md must not occupy the target slot (that would fake a deliverable).
func LocalNeedSnapshotName(need, targetDeliverable string) string {
base := filepath.Base(filepath.FromSlash(need))
if base == "" || base == "." {
return ""
}
if targetDeliverable == "" {
targetDeliverable = RepairTargetName
}
if base == targetDeliverable {
return RepairSourceOutputName
}
return base
}
func TaskMarkdownEscapesParent(task string) bool {
return strings.Contains(task, "../")
}
// TrustedLocalDeliverable returns a non-empty regular file inside subDir.
// Symlinks and paths that escape the sub-workspace are rejected.
func TrustedLocalDeliverable(subDir, name string) ([]byte, bool) {
if name == "" || strings.Contains(name, "..") {
return nil, false
}
p := filepath.Join(subDir, filepath.FromSlash(name))
info, err := os.Lstat(p)
if err != nil || !info.Mode().IsRegular() {
return nil, false
}
abs, err := filepath.Abs(p)
if err != nil {
return nil, false
}
root, err := filepath.Abs(subDir)
if err != nil {
return nil, false
}
rel, err := filepath.Rel(root, abs)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return nil, false
}
data, err := os.ReadFile(p)
if err != nil || len(bytes.TrimSpace(data)) == 0 {
return nil, false
}
return data, true
}
// SnapshotInputsIntoSubdir copies parent template/blackboard/Needs as regular
// local files. output.md needs become source_output.md so the runner target stays empty.
func SnapshotInputsIntoSubdir(wsDir, subDir string, needs []string, targetDeliverable string) error {
if targetDeliverable == "" {
targetDeliverable = RepairTargetName
}
if err := os.MkdirAll(subDir, 0o755); err != nil {
return err
}
if _, err := os.Stat(filepath.Join(wsDir, "template.md")); err == nil {
if err := copyRegularFile(filepath.Join(wsDir, "template.md"), filepath.Join(subDir, "template.md")); err != nil {
return err
}
}
if _, err := os.Stat(filepath.Join(wsDir, "blackboard.json")); err == nil {
if err := copyRegularFile(filepath.Join(wsDir, "blackboard.json"), filepath.Join(subDir, "blackboard.json")); err != nil {
return err
}
}
seen := map[string]struct{}{"template.md": {}, "blackboard.json": {}}
for _, need := range needs {
local := LocalNeedSnapshotName(need, targetDeliverable)
if local == "" || local == targetDeliverable {
continue
}
if _, dup := seen[local]; dup {
continue
}
src := filepath.Join(wsDir, filepath.FromSlash(need))
if _, err := os.Stat(src); err != nil {
continue
}
if err := copyRegularFile(src, filepath.Join(subDir, local)); err != nil {
return err
}
seen[local] = struct{}{}
}
return nil
}
func BuildContainedTaskMarkdown(step StepSpec, deliverableName string) string {
if deliverableName == "" {
deliverableName = RepairTargetName
}
var b strings.Builder
b.WriteString(fmt.Sprintf("# 任务执行指令: %s\n\n", step.Title))
b.WriteString(fmt.Sprintf("- **步骤 ID**: `%s`\n", step.ID))
b.WriteString(fmt.Sprintf("- **分配角色**: `%s`\n", step.Role))
b.WriteString(fmt.Sprintf("- **执行模式**: `%s`\n", step.Mode))
b.WriteString(fmt.Sprintf("- **交付产物**: `%s`\n\n", deliverableName))
b.WriteString("## 环境与输入导航\n")
b.WriteString("只阅读当前目录下的本地快照,禁止使用 `..` 或写入上级目录:\n")
b.WriteString("- `template.md`:交付模板\n")
b.WriteString("- `blackboard.json`:黑板事实(若存在)\n")
if len(step.Needs) > 0 {
b.WriteString("\n## 前序依赖产出\n")
for _, n := range step.Needs {
local := LocalNeedSnapshotName(n, deliverableName)
if local == "" {
continue
}
b.WriteString(fmt.Sprintf("- `%s`(由 `%s` 快照)\n", local, filepath.ToSlash(n)))
}
b.WriteString("\n")
}
if len(step.Accept) > 0 {
b.WriteString("## 验收标准(Checklist)\n")
for _, a := range step.Accept {
b.WriteString(fmt.Sprintf("- [ ] %s\n", a))
}
b.WriteString("\n")
}
b.WriteString("## 交付契约与写作纪律\n")
b.WriteString(fmt.Sprintf("请在当前目录下生成完整交付物 `%s`。不要引用或改写上级文件。\n", deliverableName))
return b.String()
}
func WriteRepairTaskMarkdown(subDir string, attempt int, report *QualityReport, stallHinted bool) error {
var b strings.Builder
b.WriteString(fmt.Sprintf("# 质量门禁修复任务 (第 %d 次修正)\n\n", attempt))
b.WriteString("前序提交未通过自动化质量门禁,检测到的问题如下,请针对性修复:\n")
if report != nil {
for _, v := range report.Violations {
b.WriteString(fmt.Sprintf("- ❌ %s\n", sanitizeViolationForAgent(v)))
}
}
if stallHinted {
b.WriteString("\n⚠️ 治理警告:最近两轮修复产物相似度过高,被判定为无进度空转。本轮必须针对上述违规点逐条重写对应章节,产出与之前实质不同的内容,否则任务将被强制终止。\n")
}
b.WriteString("\n只阅读当前目录本地快照,禁止使用上级路径:\n")
b.WriteString("- `source_output.md`:当前未通过门禁的初稿\n")
b.WriteString("- `template.md`:交付模板\n")
b.WriteString("- `blackboard.json`:黑板事实(若存在)\n")
b.WriteString("\n只在当前目录写入完整修复初稿 `output.md`。\n")
text := b.String()
if TaskMarkdownEscapesParent(text) {
return fmt.Errorf("repair TASK.md must not reference parent paths")
}
return writeRegularFile(filepath.Join(subDir, "TASK.md"), []byte(text))
}
func recordContainmentViolation(wsDir string, attempt int, mutated []string, localOK bool, snap WorkspaceInputSnapshot) {
digest := ""
if snap.OutputOK {
digest = digestBytes(snap.Output)
}
appendGovAudit(wsDir, GovAuditContainment, map[string]string{
"attempt": fmt.Sprintf("%d", attempt),
"mutated": strings.Join(mutated, ","),
"local_target": fmt.Sprintf("%v", localOK),
"output_digest": digest,
})
}
+122
View File
@@ -0,0 +1,122 @@
package squad_test
import (
"os"
"path/filepath"
"strings"
"testing"
"chat-agent-system/internal/squad"
)
func TestLocalNeedSnapshotName_DoesNotOccupyTarget(t *testing.T) {
if got := squad.LocalNeedSnapshotName("output.md", "output.md"); got != squad.RepairSourceOutputName {
t.Fatalf("root output.md must snapshot as source_output.md, got %q", got)
}
if got := squad.LocalNeedSnapshotName("tasks/03_review/output.md", "output.md"); got != squad.RepairSourceOutputName {
t.Fatalf("nested output.md must not occupy target, got %q", got)
}
if got := squad.LocalNeedSnapshotName("blackboard.json", "output.md"); got != "blackboard.json" {
t.Fatalf("other needs keep basename, got %q", got)
}
}
func TestSnapshotInputsAndContainedTaskHaveNoParentRefs(t *testing.T) {
ws := t.TempDir()
if err := os.WriteFile(filepath.Join(ws, "output.md"), []byte("BAD DRAFT"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(ws, "template.md"), []byte("# tmpl\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(ws, "blackboard.json"), []byte(`{"k":1}`), 0o644); err != nil {
t.Fatal(err)
}
sub := filepath.Join(ws, "tasks", "repair_1")
if err := squad.SnapshotInputsIntoSubdir(ws, sub, []string{"output.md"}, "output.md"); err != nil {
t.Fatal(err)
}
src, err := os.ReadFile(filepath.Join(sub, squad.RepairSourceOutputName))
if err != nil || string(src) != "BAD DRAFT" {
t.Fatalf("source snapshot: %s %v", src, err)
}
if _, err := os.Stat(filepath.Join(sub, "output.md")); err == nil {
t.Fatal("must not copy draft onto the target slot")
}
info, err := os.Lstat(filepath.Join(sub, "template.md"))
if err != nil || !info.Mode().IsRegular() {
t.Fatalf("template.md must be a regular snapshot, not a symlink: %v %v", info, err)
}
step := squad.StepSpec{
ID: "step_repair_1",
Role: "writer",
Title: "fix",
Mode: "execute",
Needs: []string{"output.md"},
}
task := squad.BuildContainedTaskMarkdown(step, "output.md")
if squad.TaskMarkdownEscapesParent(task) {
t.Fatalf("contained TASK.md still has parent refs:\n%s", task)
}
if !strings.Contains(task, squad.RepairSourceOutputName) {
t.Fatalf("TASK.md must point at local source snapshot:\n%s", task)
}
}
func TestTrustedLocalDeliverableRejectsSymlinkAndEmpty(t *testing.T) {
ws := t.TempDir()
sub := filepath.Join(ws, "tasks", "repair_1")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(ws, "output.md"), []byte("PARENT"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join("..", "..", "output.md"), filepath.Join(sub, "output.md")); err != nil {
t.Fatal(err)
}
if _, ok := squad.TrustedLocalDeliverable(sub, "output.md"); ok {
t.Fatal("symlink to parent must not count as a trusted local target")
}
_ = os.Remove(filepath.Join(sub, "output.md"))
if err := os.WriteFile(filepath.Join(sub, "output.md"), []byte(" \n"), 0o644); err != nil {
t.Fatal(err)
}
if _, ok := squad.TrustedLocalDeliverable(sub, "output.md"); ok {
t.Fatal("whitespace-only local target must be rejected")
}
}
func TestParentSnapshotRestore(t *testing.T) {
ws := t.TempDir()
orig := []byte("ORIGINAL ROOT")
if err := os.WriteFile(filepath.Join(ws, "output.md"), orig, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(ws, "template.md"), []byte("T0"), 0o644); err != nil {
t.Fatal(err)
}
snap := squad.SnapshotWorkspaceInputs(ws)
if err := os.WriteFile(filepath.Join(ws, "output.md"), []byte("MUTATED BETTER"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(ws, "template.md"), []byte("T1"), 0o644); err != nil {
t.Fatal(err)
}
changed := squad.ParentInputsMutated(ws, snap)
if len(changed) != 2 {
t.Fatalf("expected output+template mutated, got %v", changed)
}
if err := squad.RestoreWorkspaceInputs(ws, snap); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(filepath.Join(ws, "output.md"))
if string(got) != string(orig) {
t.Fatalf("root output.md not restored, got %q", got)
}
gotT, _ := os.ReadFile(filepath.Join(ws, "template.md"))
if string(gotT) != "T0" {
t.Fatalf("template.md not restored, got %q", gotT)
}
}
+23
View File
@@ -503,6 +503,29 @@ func (e *WorkspaceEngine) buildSubWorkspaces(workDir string, wf WorkflowPlan, sq
}
var taskBuilder strings.Builder
if step.InputLayout == InputLayoutSnapshot {
if err := SnapshotInputsIntoSubdir(workDir, subDir, step.Needs, deliverableName); err != nil {
return err
}
taskText := BuildContainedTaskMarkdown(step, deliverableName)
if TaskMarkdownEscapesParent(taskText) {
return fmt.Errorf("snapshot TASK.md must not reference parent paths")
}
if err := writeRegularFile(filepath.Join(subDir, "TASK.md"), []byte(taskText)); err != nil {
return err
}
inputRefs := map[string]any{
"step_id": step.ID,
"role": step.Role,
"needs": step.Needs,
"target_file": deliverableName,
"input_layout": InputLayoutSnapshot,
"forbid_parent_paths": true,
}
_ = writeJSON(filepath.Join(subDir, "input_refs.json"), inputRefs)
continue
}
taskBuilder.WriteString(fmt.Sprintf("# 任务执行指令: %s\n\n", step.Title))
taskBuilder.WriteString(fmt.Sprintf("- **步骤 ID**: `%s`\n", step.ID))
taskBuilder.WriteString(fmt.Sprintf("- **分配角色**: `%s`\n", step.Role))