merge: fix/document-revision-quality-repair-workspace-contract

This commit is contained in:
weichen
2026-09-13 20:21:05 +00:00
13 changed files with 1129 additions and 54 deletions
@@ -0,0 +1,56 @@
---
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 |
| 提升谓词(独立审查) | 曾在 `runErr != nil` 或 containment 后仍因 `localOK` 提升 | 截断产物或越界轮次会被当成修复成功 |
| 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 的路径)
- **剩余风险(保护范围)**:containment 只快照/恢复 `output.md` / `template.md` / `blackboard.json`。模型改写其它父级文件(如 `PLAN.md`、其它 `tasks/*`)检测不到、不会回滚。创建/删除这三份受保护输入已计入 mutation。
+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,57 @@
---
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] 独立审查 fail-open:`runErr` / containment 不得提升;restore 失败硬返回;创建/删除计入 mutation
4. [x] gofmt / vet / `go test -short ./...` / `-race` squad+agent / settle-policy.test.mjs / `git diff --check`
5. [ ] 真实 UI-03(本轮不做)
## 验收标准
- [x] 仅 `runErr==nil` ∧ 无 containment ∧ `TrustedLocalDeliverable=true` 才覆盖根并重验
- [x] 越界(含创建/删除受保护父级)恢复后不提升,报告 FAIL
- [x] backend 失败/超时即使留下本地目标也不提升
- [x] restore 失败立即返回,可测策略能断言错误传播
- [ ] 现场 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`
- 验证(fail-closed 追加):`gofmt` / `go vet ./...` / `go test -short ./...` / `go test -short -race ./internal/squad/ ./internal/agent/` / `node --test deploy/pi-agent/settle-policy.test.mjs` / `git diff --check` PASS
- 保护范围仅 `output.md` / `template.md` / `blackboard.json`;其它父级文件是剩余风险
- **未跑真实 UI-03,不得标 done**;**不得 merge / rebuild / E2E / push**
@@ -0,0 +1,55 @@
---
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
- 仅当 `runErr==nil` **且** 无受保护父级越界 **且** `TrustedLocalDeliverable=true` 时覆盖根文件并重新 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 fail-closed**:受保护父级(`output.md` / `template.md` / `blackboard.json`)内容变化、创建或删除都算越界。先恢复运行前存在性/字节并写 `gov_audit.jsonl`。**恢复后不提升本地产物**,本轮作废,进入下一预算轮或结束。恢复失败立即返回错误,禁止继续覆盖。
5. **唯一提升谓词**:`DecideRepairPromote` 要求 `runErr==nil` ∧ 无 containment mutation ∧ `TrustedLocalDeliverable=true`,才覆盖根 `output.md` 并 `Validate`。backend 超时/失败后留下的本地文件不得提升。
6. 默认步骤仍走原 `../../` + symlink,避免影响正常子任务读 context。
7. **剩余风险**:未列入 `ProtectedParentInputs` 的父级文件不在快照范围内。
## 验收标准
- [x] 原稿缺章节 → 本地修复产物补齐 → 根覆盖 → revalidate PASS(仅三重谓词全绿)
- [x] 只越界改父级、缺本地目标 → 父级恢复、FAIL + containment 审计
- [x] 父级与本地同时改:恢复父级、**不提升**本地、报告 FAIL
- [x] backend 写有效本地目标后返回 error:0 次提升、根保持运行前字节、FAIL
- [x] 初始缺失的受保护父级被创建:视为 mutation,恢复后删除、不提升
- [x] restore 失败立即返回错误并写 `repair_restore_failure` 审计
- [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"` // 💡 往期标杆案例与历史报价经验自推荐
}
+56 -37
View File
@@ -537,7 +537,8 @@ func (q *QualityGate) GenerateManifest(wsDir string, docKey string, report Quali
return manifest, nil
}
// EnsureQualityOrRepair 质量门禁自动闭环:若质检未通过,动态向工作流追加修复步骤直到达标或达到重试上限
// EnsureQualityOrRepair 质量门禁自动闭环:若质检未通过,动态向工作流追加修复步骤直到达标或达到重试上限。
// 提升根 output.md 的唯一条件:runErr==nil、无受保护父级越界、本地目标可信。
func (q *QualityGate) EnsureQualityOrRepair(
ctx context.Context,
wsDir string,
@@ -600,57 +601,75 @@ 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 {
break
repairedBytes, localOK := TrustedLocalDeliverable(repairAbs, RepairTargetName)
mutated := ParentInputsMutated(wsDir, parentSnap)
if len(mutated) > 0 {
recordContainmentViolation(wsDir, attempt, mutated, localOK, parentSnap)
if rerr := restoreWorkspaceInputsFn(wsDir, parentSnap); rerr != nil {
appendGovAudit(wsDir, GovAuditRestoreFailure, map[string]string{
"attempt": fmt.Sprintf("%d", attempt),
"error": rerr.Error(),
"mutated": strings.Join(mutated, ","),
})
if st := stream.FromContext(ctx); st != nil {
st.EmitPhase("governance", "修复 containment 恢复失败: "+rerr.Error(), "taskgov", 0, 0, 0)
}
return report, fmt.Errorf("restore parent inputs after containment: %w", rerr)
}
if st := stream.FromContext(ctx); st != nil {
st.EmitPhase("governance", "修复 containment 违规: 父级输入已被越界改写,已恢复运行前字节,本轮作废", "taskgov", 0, 0, 0)
}
}
// 读出修复后的产物并覆盖根目录 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)})
promote, reason := DecideRepairPromote(runErr, mutated, localOK)
if !promote {
detail := map[string]string{"attempt": fmt.Sprintf("%d", attempt), "reason": reason}
if runErr != nil {
detail["error"] = runErr.Error()
}
appendGovAudit(wsDir, "repair_round_discarded", detail)
continue
}
// 仅 runErr==nil、无 containment、本地目标可信时才覆盖根 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,334 @@
package squad_test
import (
"context"
"fmt"
"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
}
type createParentInputBackend struct{ body string }
func (b *createParentInputBackend) Name() string { return "create-parent-input" }
func (b *createParentInputBackend) Execute(_ context.Context, req agent.Request) (*agent.Result, error) {
parent := filepath.Clean(filepath.Join(req.WorkDir, "..", "..", "blackboard.json"))
if err := os.WriteFile(parent, []byte(`{"created":true}`), 0o644); err != nil {
return nil, err
}
return &agent.Result{
Success: true,
Patches: map[string][]byte{"output.md": []byte(b.body)},
}, nil
}
type localThenErrorBackend struct{ body string }
func (b *localThenErrorBackend) Name() string { return "local-then-error" }
func (b *localThenErrorBackend) Execute(_ context.Context, req agent.Request) (*agent.Result, error) {
if err := os.WriteFile(filepath.Join(req.WorkDir, "output.md"), []byte(b.body), 0o644); err != nil {
return nil, err
}
return &agent.Result{Success: false, ErrorReason: "backend timeout"}, fmt.Errorf("backend timeout")
}
// Local repair is promoted only when backend succeeds, parents stay untouched, and the local target is trusted.
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_BothChangedFailsClosedNoPromote(t *testing.T) {
workDir := prepareRepairWS(t)
orig, _ := os.ReadFile(filepath.Join(workDir, "output.md"))
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("containment must void the local repair even if it looks complete: %+v", report)
}
root, _ := os.ReadFile(filepath.Join(workDir, "output.md"))
if string(root) != string(orig) {
t.Fatalf("root must stay pre-repair bytes, got %q", root)
}
if strings.Contains(string(root), "越界父级不应被采用") || strings.Contains(string(root), "详细方案") {
t.Fatalf("must not accept escaped parent or local repair after containment: %s", root)
}
audit, _ := os.ReadFile(filepath.Join(workDir, "gov_audit.jsonl"))
if !strings.Contains(string(audit), squad.GovAuditContainment) {
t.Fatalf("both-changed must record containment, got %s", audit)
}
}
func TestEnsureQualityOrRepair_RunErrorDoesNotPromoteLocalTarget(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, &localThenErrorBackend{body: repairGoodDraft}, 1, squad.ExecuteOptions{
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("EnsureQualityOrRepair: %v", err)
}
if report == nil || report.Passed {
t.Fatalf("backend error must keep quality_failed: %+v", report)
}
root, _ := os.ReadFile(filepath.Join(workDir, "output.md"))
if string(root) != string(orig) {
t.Fatalf("0 promote expected; root changed to %q", root)
}
if _, err := os.Stat(filepath.Join(workDir, "tasks", "repair_1", "output.md")); err != nil {
t.Fatalf("local target may exist after timeout, but must not be promoted: %v", err)
}
}
func TestEnsureQualityOrRepair_CreatedParentInputFailsClosed(t *testing.T) {
workDir := prepareRepairWS(t)
if err := os.Remove(filepath.Join(workDir, "blackboard.json")); err != nil {
t.Fatal(err)
}
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, &createParentInputBackend{body: repairGoodDraft}, 1, squad.ExecuteOptions{
Timeout: 5 * time.Second,
})
if err != nil {
t.Fatalf("EnsureQualityOrRepair: %v", err)
}
if report == nil || report.Passed {
t.Fatalf("creating a missing protected parent input must void the round: %+v", report)
}
root, _ := os.ReadFile(filepath.Join(workDir, "output.md"))
if string(root) != string(orig) {
t.Fatalf("must not promote local repair after parent create, got %q", root)
}
if _, err := os.Stat(filepath.Join(workDir, "blackboard.json")); err == nil {
t.Fatal("restore must remove the created parent blackboard.json")
}
audit, _ := os.ReadFile(filepath.Join(workDir, "gov_audit.jsonl"))
if !strings.Contains(string(audit), squad.GovAuditContainment) || !strings.Contains(string(audit), "blackboard.json") {
t.Fatalf("created parent input must be audited as containment, got %s", audit)
}
}
func TestEnsureQualityOrRepair_RestoreFailureStopsWithoutPromote(t *testing.T) {
workDir := prepareRepairWS(t)
reset := squad.OverrideRestoreWorkspaceInputsForTest(func(string, squad.WorkspaceInputSnapshot) error {
return fmt.Errorf("restore output.md: injected")
})
defer reset()
driver := squad.NewWorkflowDriver(squad.NewWorkspaceEngine())
gate := squad.NewQualityGate()
report, err := gate.EnsureQualityOrRepair(context.Background(), workDir, "intake", driver, &bothWriteBackend{
local: repairGoodDraft,
parent: repairGoodDraft + "\n\n## 越界父级不应被采用\n",
}, 1, squad.ExecuteOptions{Timeout: 5 * time.Second})
if err == nil || !strings.Contains(err.Error(), "restore parent inputs after containment") {
t.Fatalf("restore failure must return immediately, got report=%+v err=%v", report, err)
}
if report != nil && report.Passed {
t.Fatal("must not mark quality passed after restore failure")
}
root, _ := os.ReadFile(filepath.Join(workDir, "output.md"))
if !strings.Contains(string(root), "越界父级不应被采用") {
t.Fatalf("failed restore must not continue into local promote; root=%s", root)
}
audit, _ := os.ReadFile(filepath.Join(workDir, "gov_audit.jsonl"))
if !strings.Contains(string(audit), squad.GovAuditRestoreFailure) {
t.Fatalf("expected restore failure audit, 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)
}
}
+323
View File
@@ -0,0 +1,323 @@
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"
GovAuditRestoreFailure = "repair_restore_failure"
RepairPromoteOK = "ok"
RepairDiscardRunError = "run_error"
RepairDiscardContainment = "containment"
RepairDiscardNoLocal = "missing_local"
)
// ProtectedParentInputs are the only parent files snapshotted for repair
// containment. Other workspace files are residual risk if the model writes them.
var ProtectedParentInputs = []string{"output.md", "template.md", "blackboard.json"}
// 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 restoreProtectedFile(path string, existed bool, data []byte) error {
_, statErr := os.Lstat(path)
nowExists := statErr == nil
if existed {
if err := writeRegularFile(path, data); err != nil {
return fmt.Errorf("restore %s: %w", filepath.Base(path), err)
}
return nil
}
if nowExists {
if err := os.RemoveAll(path); err != nil {
return fmt.Errorf("restore: remove created %s: %w", filepath.Base(path), err)
}
}
return nil
}
// restoreWorkspaceInputsFn is the restore strategy used by EnsureQualityOrRepair.
// Tests replace it via OverrideRestoreWorkspaceInputsForTest.
var restoreWorkspaceInputsFn = RestoreWorkspaceInputs
// OverrideRestoreWorkspaceInputsForTest swaps the restore strategy and returns a reset func.
func OverrideRestoreWorkspaceInputsForTest(fn func(string, WorkspaceInputSnapshot) error) func() {
prev := restoreWorkspaceInputsFn
if fn == nil {
restoreWorkspaceInputsFn = RestoreWorkspaceInputs
} else {
restoreWorkspaceInputsFn = fn
}
return func() { restoreWorkspaceInputsFn = prev }
}
func RestoreWorkspaceInputs(wsDir string, snap WorkspaceInputSnapshot) error {
if err := restoreProtectedFile(filepath.Join(wsDir, "output.md"), snap.OutputOK, snap.Output); err != nil {
return err
}
if err := restoreProtectedFile(filepath.Join(wsDir, "template.md"), snap.TemplateOK, snap.Template); err != nil {
return err
}
if err := restoreProtectedFile(filepath.Join(wsDir, "blackboard.json"), snap.BlackboardOK, snap.Blackboard); err != nil {
return err
}
return nil
}
func parentFileMutated(path string, existed bool, data []byte) bool {
cur, err := os.ReadFile(path)
nowExists := err == nil
if existed != nowExists {
return true
}
if existed && !bytes.Equal(cur, data) {
return true
}
return false
}
func ParentInputsMutated(wsDir string, snap WorkspaceInputSnapshot) []string {
var changed []string
if parentFileMutated(filepath.Join(wsDir, "output.md"), snap.OutputOK, snap.Output) {
changed = append(changed, "output.md")
}
if parentFileMutated(filepath.Join(wsDir, "template.md"), snap.TemplateOK, snap.Template) {
changed = append(changed, "template.md")
}
if parentFileMutated(filepath.Join(wsDir, "blackboard.json"), snap.BlackboardOK, snap.Blackboard) {
changed = append(changed, "blackboard.json")
}
return changed
}
// DecideRepairPromote is the only gate that may overlay root output.md.
// runErr, any protected-parent mutation, or an untrusted local target each void the round.
func DecideRepairPromote(runErr error, mutated []string, localOK bool) (promote bool, reason string) {
if runErr != nil {
return false, RepairDiscardRunError
}
if len(mutated) > 0 {
return false, RepairDiscardContainment
}
if !localOK {
return false, RepairDiscardNoLocal
}
return true, RepairPromoteOK
}
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,
})
}
+194
View File
@@ -0,0 +1,194 @@
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 TestDecideRepairPromote(t *testing.T) {
if ok, reason := squad.DecideRepairPromote(nil, nil, true); !ok || reason != squad.RepairPromoteOK {
t.Fatalf("clean success must promote, got %v %s", ok, reason)
}
if ok, reason := squad.DecideRepairPromote(assertErr{}, nil, true); ok || reason != squad.RepairDiscardRunError {
t.Fatalf("runErr must void promote, got %v %s", ok, reason)
}
if ok, reason := squad.DecideRepairPromote(nil, []string{"output.md"}, true); ok || reason != squad.RepairDiscardContainment {
t.Fatalf("mutation must void promote, got %v %s", ok, reason)
}
if ok, reason := squad.DecideRepairPromote(nil, nil, false); ok || reason != squad.RepairDiscardNoLocal {
t.Fatalf("missing local must void promote, got %v %s", ok, reason)
}
}
type assertErr struct{}
func (assertErr) Error() string { return "backend timeout" }
func TestParentInputsMutated_CreateAndDelete(t *testing.T) {
ws := t.TempDir()
if err := os.WriteFile(filepath.Join(ws, "output.md"), []byte("OUT"), 0o644); err != nil {
t.Fatal(err)
}
snap := squad.SnapshotWorkspaceInputs(ws)
if snap.BlackboardOK {
t.Fatal("blackboard must start missing")
}
if err := os.WriteFile(filepath.Join(ws, "blackboard.json"), []byte(`{"created":true}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(ws, "output.md")); err != nil {
t.Fatal(err)
}
changed := squad.ParentInputsMutated(ws, snap)
want := map[string]bool{"output.md": true, "blackboard.json": true}
if len(changed) != 2 {
t.Fatalf("create+delete must both count, got %v", changed)
}
for _, name := range changed {
if !want[name] {
t.Fatalf("unexpected mutation %s in %v", name, changed)
}
}
if err := squad.RestoreWorkspaceInputs(ws, snap); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(filepath.Join(ws, "output.md"))
if err != nil || string(got) != "OUT" {
t.Fatalf("deleted output.md must be restored, got %q %v", got, err)
}
if _, err := os.Stat(filepath.Join(ws, "blackboard.json")); err == nil {
t.Fatal("created blackboard.json must be removed")
}
}
func TestRestoreWorkspaceInputs_PropagatesWriteError(t *testing.T) {
ws := t.TempDir()
snap := squad.WorkspaceInputSnapshot{OutputOK: true, Output: []byte("x")}
blocked := filepath.Join(ws, "output.md")
if err := os.MkdirAll(filepath.Join(blocked, "nested"), 0o755); err != nil {
t.Fatal(err)
}
err := squad.RestoreWorkspaceInputs(ws, snap)
if err == nil {
t.Fatal("restore must fail when output.md is a non-empty directory")
}
if !strings.Contains(err.Error(), "restore") {
t.Fatalf("error must name restore, got %v", err)
}
}
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))