merge: fix/document-revision-quality-repair-workspace-contract
This commit is contained in:
@@ -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。
|
||||
@@ -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
@@ -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/`
|
||||
@@ -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) | 智能体版本、人工修订、差异认知与负责人通知 |
|
||||
|
||||
@@ -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
@@ -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"` // 💡 往期标杆案例与历史报价经验自推荐
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user