From b7cfb282170c71425f3097372bd9cd90a4989bc4 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 20:15:54 +0000 Subject: [PATCH 1/2] fix(squad): keep quality repair inside a local snapshot workspace Repair TASK.md told the model to edit ../../output.md, so the local target never appeared and UI-03 stayed quality_failed. Snapshot parent inputs locally, restore them on containment violations, and promote only a trusted repair/output.md before revalidate. Co-authored-by: Cursor --- ...ision-quality-repair-workspace-contract.md | 54 ++++ .doc/audit/INDEX.md | 8 + .doc/ops/CURRENT.md | 8 +- ...ision-quality-repair-workspace-contract.md | 53 ++++ ...ision-quality-repair-workspace-contract.md | 51 ++++ .doc/remediation/INDEX.md | 1 + internal/agent/deliverable_test.go | 1 + internal/squad/models.go | 30 +- internal/squad/quality_gate.go | 78 ++--- .../squad/quality_repair_contract_test.go | 226 +++++++++++++++ internal/squad/repair_workspace.go | 270 ++++++++++++++++++ internal/squad/repair_workspace_test.go | 122 ++++++++ internal/squad/workspace.go | 23 ++ 13 files changed, 873 insertions(+), 52 deletions(-) create mode 100644 .doc/audit/2026-09-13-document-revision-quality-repair-workspace-contract.md create mode 100644 .doc/ops/short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md create mode 100644 .doc/remediation/2026-09-13-document-revision-quality-repair-workspace-contract.md create mode 100644 internal/squad/quality_repair_contract_test.go create mode 100644 internal/squad/repair_workspace.go create mode 100644 internal/squad/repair_workspace_test.go diff --git a/.doc/audit/2026-09-13-document-revision-quality-repair-workspace-contract.md b/.doc/audit/2026-09-13-document-revision-quality-repair-workspace-contract.md new file mode 100644 index 00000000..e91738bb --- /dev/null +++ b/.doc/audit/2026-09-13-document-revision-quality-repair-workspace-contract.md @@ -0,0 +1,54 @@ +--- +title: "UI-03 质量修复子工作区越界改写根 output.md" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +severity: high +--- + +# UI-03 质量修复子工作区越界改写根 output.md + +## 现象 + +第13轮 Antigravity 完整 UI(schema 30,dev@8b1a531 基线):UI00–02 PASS,UI-03 真实 PI 终态 `quality_failed`,页面 70 分、缺少必填章节「阻塞信息 / 非阻塞信息」。 + +真实工作区 `/app/data/workspaces/bizdev/intake_1789329815528283509`: + +- `tasks/03_review/output.md` 于 20:07:02 **确实没有**两个章节;质量门禁判定正确,不得放宽必填章节。 +- `EnsureQualityOrRepair` 创建 `tasks/repair_1`,TASK.md 同时写「参考 `../../template.md`、基于 `../../output.md`、输出到 `output.md`」。 +- 真实模型读取父级文件后,`edit` 写了 `tasks/repair_1/../../output.md`(根 `output.md`);根文件 20:07:24 已补齐两章节。 +- `tasks/repair_1/output.md` **从未生成**;runner 目标仍是本地 `output.md` → `missing target` → `runOneStep` 失败 → `EnsureQualityOrRepair` break → manifest 保留原 70 分。 +- runner `execution.log` 的 `Outcome: SUCCESS` 只是模型 settle,不代表目标契约成功。 + +## 根因分析 + +| 层 | 问题 | 后果 | +|---|---|---| +| TASK 契约 | 修复步骤把输入写成 `../../output.md` / `../../template.md` | 模型按字面去 edit 父级 | +| 沙箱 | `buildSubWorkspaces` 对 template/blackboard 建 **symlink** | 子目录写同名文件会打穿父级 | +| Needs | 只在 TASK 里列 `../../need`,不把原稿快照成非目标文件名 | 若复制成本地 `output.md` 会假成功;当前则诱导越界 | +| 成功判定 | 只认本地 `output.md`(正确),但失败后不恢复被改的根文件 | 根文件「看起来更好」却不覆盖/不重验,页面仍 70 | +| settle | 内部 Outcome SUCCESS ≠ 本地目标存在 | 不得把 settle 当交付 | + +`MatchDeliverableRel` 已拒绝 `../`,因此 missing-target fail-closed 正确,**不能**因此把越界根文件当成功。 + +## 严重度 + +`high` + +自动修复轮空转:模型可能已经写好章节,契约失败导致任务 `quality_failed`。 + +## 关联整改 + +- Remediation: [../remediation/2026-09-13-document-revision-quality-repair-workspace-contract.md](../remediation/2026-09-13-document-revision-quality-repair-workspace-contract.md) +- Short-term: [../ops/short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md](../ops/short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md) +- 分支: `fix/document-revision-quality-repair-workspace-contract` + +## 证据 + +- 失败证据(根代理保留、本轮不得改/移/删/提交):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui03-required-sections-quality-failure/` + +## 开放问题 + +- 本轮不跑真实 UI-03;不得标 E2E 通过或任务 `done` +- 默认子步骤仍可用 `../../` + symlink(未改正常步骤读本地 context 的路径) diff --git a/.doc/audit/INDEX.md b/.doc/audit/INDEX.md index cc715b17..a44465fd 100644 --- a/.doc/audit/INDEX.md +++ b/.doc/audit/INDEX.md @@ -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。 diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index c36ae0e4..c6a83da3 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -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/` 或 `fix/`,**禁止** `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) | diff --git a/.doc/ops/short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md b/.doc/ops/short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md new file mode 100644 index 00000000..da2df27d --- /dev/null +++ b/.doc/ops/short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md @@ -0,0 +1,53 @@ +--- +title: "质量修复子工作区契约" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +owner: Cursor-Auto +branch: fix/document-revision-quality-repair-workspace-contract +worktree: "-" +mutex: document-revision-quality-repair-workspace-contract +--- + +# 质量修复子工作区契约 + +## 目标 + +堵住第13轮 UI-03:repair 步 TASK 引导 `../../output.md`,模型越界改根文件,本地目标缺失导致 `quality_failed` 停在 70。门禁判定保持,修复契约改为本地快照 + containment。 + +## 范围 + +- `internal/squad/repair_workspace.go` 与单测 +- `EnsureQualityOrRepair` / `buildSubWorkspaces` snapshot 布局 +- audit / remediation / CURRENT + +## 非目标 + +- 不放宽必填章节;不把越界根文件当成功;不隐藏 `quality_failed` +- 不改默认步骤的 `../../` context 导航 +- 不碰证据目录;不合 dev;不 rebuild Docker;不 E2E;不 push + +## 步骤 + +1. [x] 从 `dev@8b1a531` 开分支并登记 mutex +2. [x] 快照契约 + containment + 测试 +3. [x] gofmt / vet / `go test -short ./...` / `-race` squad+agent / settle-policy.test.mjs +4. [ ] 真实 UI-03(本轮不做) + +## 验收标准 + +- [x] 本地可信产物才覆盖根并重验 +- [x] 越界无本地目标则恢复父级并 fail-closed +- [ ] 现场 UI-03(未跑) + +## 相关链接 + +- Audit: [../../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md](../../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md) +- Remediation: [../../remediation/2026-09-13-document-revision-quality-repair-workspace-contract.md](../../remediation/2026-09-13-document-revision-quality-repair-workspace-contract.md) + +## 证据 / 备注 + +- mutex:`document-revision-quality-repair-workspace-contract`(新 mutex,不与现有 in_progress 冲突) +- 基线:`dev@8b1a531` +- 验证:`go test -short ./...` PASS;`go test -short -race ./internal/squad/ ./internal/agent/` PASS;`settle-policy.test.mjs` PASS +- **未跑真实 UI-03,不得标 done** diff --git a/.doc/remediation/2026-09-13-document-revision-quality-repair-workspace-contract.md b/.doc/remediation/2026-09-13-document-revision-quality-repair-workspace-contract.md new file mode 100644 index 00000000..7519ca69 --- /dev/null +++ b/.doc/remediation/2026-09-13-document-revision-quality-repair-workspace-contract.md @@ -0,0 +1,51 @@ +--- +title: "质量修复子工作区输入输出契约整改" +status: in_progress +created: 2026-09-13 +updated: 2026-09-13 +--- + +# 质量修复子工作区输入输出契约整改 + +## 背景 + +链到 [../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md](../audit/2026-09-13-document-revision-quality-repair-workspace-contract.md)。 + +不得放宽/删除必填章节;不得把越界改写的根 `output.md` 当成功;不得隐藏 `quality_failed`。 + +## 目标 + +- 修复步只消费本地快照、只写当前目录 `output.md` +- 越界改父级且无可信本地目标:恢复运行前字节并 fail-closed +- 仅当本地 `repair/output.md` 非空且可信时覆盖根文件并重新 Validate +- 重复/预算语义不回归;runner missing-target 仍 fail-closed + +## 分波次计划 + +| 波次 | 内容 | 验收 | +|------|------|------| +| Wave 0 | `InputLayout=snapshot` + containment + 单测 | 本地补齐→覆盖→PASS;越界恢复→FAIL;TASK 无 `../` | +| Wave 1 | 占槽后真实 UI-03(本轮不做) | 现场不再因 repair 越界停在 70 | + +## 设计 + +1. **快照而非 `../../`**:`AddStep` 对 repair 使用 `InputLayoutSnapshot`。把 `output.md` 复制为 `source_output.md`(绝不占目标槽),`template.md` / `blackboard.json` 复制为常规文件(去 symlink)。 +2. **TASK.md**:质量说明只引用本地名,禁止 `../`。 +3. **运行前快照**:根 `output.md` / `template.md` / `blackboard.json` 字节。 +4. **containment**:父级任一输入被改 → 先恢复运行前字节并写 `gov_audit.jsonl`。无可信本地目标则 break(保持原报告)。父级与本地同时改:恢复父级后**只提升本地**目标。 +5. **成功路径**:`TrustedLocalDeliverable`(常规文件、非空、不逃逸)→ 覆盖根 `output.md` → `Validate`。 +6. 默认步骤仍走原 `../../` + symlink,避免影响正常子任务读 context。 + +## 验收标准 + +- [x] 原稿缺章节 → 本地修复产物补齐 → 根覆盖 → revalidate PASS +- [x] 只越界改父级、缺本地目标 → 父级恢复、FAIL + containment 审计 +- [x] 父级与本地同时改:恢复父级、提升本地 +- [x] TASK.md / 工作区构造不含 `../` 输入引用 +- [x] 修复预算仍消耗一轮;missing-target fail-closed +- [ ] 真实 UI-03(本轮不做) + +## 相关链接 + +- Audit / Short-term:同目录 2026-09-13-document-revision-quality-repair-workspace-contract +- 证据(只读):`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/schema30-ui03-required-sections-quality-failure/` diff --git a/.doc/remediation/INDEX.md b/.doc/remediation/INDEX.md index fad92970..eae02cb1 100644 --- a/.doc/remediation/INDEX.md +++ b/.doc/remediation/INDEX.md @@ -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) | 智能体版本、人工修订、差异认知与负责人通知 | diff --git a/internal/agent/deliverable_test.go b/internal/agent/deliverable_test.go index 2b4ea7dc..1ae1d3af 100644 --- a/internal/agent/deliverable_test.go +++ b/internal/agent/deliverable_test.go @@ -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}, } diff --git a/internal/squad/models.go b/internal/squad/models.go index 46b62103..38fa588c 100644 --- a/internal/squad/models.go +++ b/internal/squad/models.go @@ -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"` // 💡 往期标杆案例与历史报价经验自推荐 } diff --git a/internal/squad/quality_gate.go b/internal/squad/quality_gate.go index 6beae229..af93a9a8 100644 --- a/internal/squad/quality_gate.go +++ b/internal/squad/quality_gate.go @@ -600,57 +600,65 @@ func (q *QualityGate) EnsureQualityOrRepair( SubWorkspace: subDir, Mode: "execute", Needs: []string{"output.md"}, + InputLayout: InputLayoutSnapshot, Accept: acceptCriteria, } + parentSnap := SnapshotWorkspaceInputs(wsDir) + if err := driver.AddStep(wsDir, repairStep); err != nil { return report, fmt.Errorf("add repair step: %w", err) } - // 定制该修复子任务的 TASK.md 增加门禁报错说明 - repairTaskPath := filepath.Join(wsDir, filepath.FromSlash(subDir), "TASK.md") - var b strings.Builder - b.WriteString(fmt.Sprintf("# 质量门禁修复任务 (第 %d 次修正)\n\n", attempt)) - b.WriteString("前序提交未通过自动化质量门禁,检测到的问题如下,请针对性修复:\n") - for _, v := range report.Violations { - b.WriteString(fmt.Sprintf("- ❌ %s\n", sanitizeViolationForAgent(v))) + repairAbs := filepath.Join(wsDir, filepath.FromSlash(subDir)) + if err := WriteRepairTaskMarkdown(repairAbs, attempt, report, stallHinted); err != nil { + return report, fmt.Errorf("write repair TASK.md: %w", err) } - // 治理:停滞升级——上轮修复产物与再上轮几乎相同,注入反重复指令换策略 - if stallHinted { - b.WriteString("\n⚠️ 治理警告:最近两轮修复产物相似度过高,被判定为无进度空转。本轮必须针对上述违规点逐条重写对应章节,产出与之前实质不同的内容,否则任务将被强制终止。\n") - } - b.WriteString("\n请参考 `../../template.md`,并基于根目录 `../../output.md`(当前未通过门禁的初稿)与黑板事实,补全并展开所有章节,输出完整的修复初稿到 `output.md`。\n") - _ = os.WriteFile(repairTaskPath, []byte(b.String()), 0o644) // 执行修复步骤(治理入账:轨迹 + 预算) runErr := driver.runOneStep(ctx, wsDir, repairStep, backend, opts.Timeout, opts.StepNotify, nil, &[]string{}, ledger) - if runErr != nil { + + repairedBytes, localOK := TrustedLocalDeliverable(repairAbs, RepairTargetName) + mutated := ParentInputsMutated(wsDir, parentSnap) + if len(mutated) > 0 { + _ = RestoreWorkspaceInputs(wsDir, parentSnap) + recordContainmentViolation(wsDir, attempt, mutated, localOK, parentSnap) + if st := stream.FromContext(ctx); st != nil { + st.EmitPhase("governance", "修复 containment 违规: 父级输入已被越界改写,已恢复运行前字节", "taskgov", 0, 0, 0) + } + } + + if !localOK { + if runErr != nil { + appendGovAudit(wsDir, "repair_local_target_missing", map[string]string{ + "attempt": fmt.Sprintf("%d", attempt), + "error": runErr.Error(), + }) + } break } - // 读出修复后的产物并覆盖根目录 output.md - repairedOutPath := filepath.Join(wsDir, filepath.FromSlash(subDir), "output.md") - if repairedBytes, err := os.ReadFile(repairedOutPath); err == nil && len(repairedBytes) > 0 { - // 治理:跨修复轮停滞检测(相似度 ≥ 0.98 判定无进度;二次停滞直接止损) - sim := OutputSimilarity(prevRepairContent, string(repairedBytes)) - if sim >= 0.98 { - if stallHinted { - if st := stream.FromContext(ctx); st != nil { - st.EmitPhase("governance", fmt.Sprintf("修复停滞止损: 连续两轮修复产物相似度 %.1f%%,终止机械重试", sim*100), "taskgov", 0, 0, 0) - } - appendGovAudit(wsDir, "repair_stall_break", map[string]string{"attempt": fmt.Sprintf("%d", attempt), "similarity": fmt.Sprintf("%.3f", sim)}) - stallBreak = true - } else { - stallHinted = true - appendGovAudit(wsDir, "repair_stall_hinted", map[string]string{"attempt": fmt.Sprintf("%d", attempt), "similarity": fmt.Sprintf("%.3f", sim)}) + // 仅当本地 repair/output.md 非空且可信时覆盖根 output.md,再重新 Validate。 + sim := OutputSimilarity(prevRepairContent, string(repairedBytes)) + if sim >= 0.98 { + if stallHinted { + if st := stream.FromContext(ctx); st != nil { + st.EmitPhase("governance", fmt.Sprintf("修复停滞止损: 连续两轮修复产物相似度 %.1f%%,终止机械重试", sim*100), "taskgov", 0, 0, 0) } + appendGovAudit(wsDir, "repair_stall_break", map[string]string{"attempt": fmt.Sprintf("%d", attempt), "similarity": fmt.Sprintf("%.3f", sim)}) + stallBreak = true + } else { + stallHinted = true + appendGovAudit(wsDir, "repair_stall_hinted", map[string]string{"attempt": fmt.Sprintf("%d", attempt), "similarity": fmt.Sprintf("%.3f", sim)}) } - prevRepairContent = string(repairedBytes) - _ = os.WriteFile(outPath, repairedBytes, 0o644) - report, _ = q.Validate(wsDir, taskKey, string(repairedBytes)) - if report.Passed || stallBreak { - break - } + } + prevRepairContent = string(repairedBytes) + if err := writeRegularFile(outPath, repairedBytes); err != nil { + return report, fmt.Errorf("promote repair output: %w", err) + } + report, _ = q.Validate(wsDir, taskKey, string(repairedBytes)) + if report.Passed || stallBreak { + break } } _ = ledger.Save(wsDir) diff --git a/internal/squad/quality_repair_contract_test.go b/internal/squad/quality_repair_contract_test.go new file mode 100644 index 00000000..6744ef0b --- /dev/null +++ b/internal/squad/quality_repair_contract_test.go @@ -0,0 +1,226 @@ +package squad_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "chat-agent-system/internal/agent" + "chat-agent-system/internal/squad" +) + +const repairTemplate = "# 用户初始需求收集表\n\n## 1. 结论\n\n## 2. 详细方案\n" + +const repairBadDraft = "# 用户初始需求收集表\n\n## 1. 结论\n由于篇幅有限,不做展开。\n" + +const repairGoodDraft = `# 用户初始需求收集表 + +## 1. 结论 +本项目面向专业医疗机构,采集与供电方案已按材料展开。 + +## 2. 详细方案 +覆盖信号采集、电磁兼容与供电冗余,并标注出处〔材料: 需求表 §2〕。 +` + +func prepareRepairWS(t *testing.T) string { + t.Helper() + engine := squad.NewWorkspaceEngine() + ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ + WorkDir: t.TempDir(), + TaskKey: "intake", + StageTitle: "用户初始需求收集表", + TemplateContent: repairTemplate, + }) + if err != nil { + t.Fatalf("PrepareWorkspace: %v", err) + } + if err := os.WriteFile(filepath.Join(ws.WorkDir, "output.md"), []byte(repairBadDraft), 0o644); err != nil { + t.Fatal(err) + } + return ws.WorkDir +} + +type localRepairBackend struct{ body string } + +func (b *localRepairBackend) Name() string { return "local-repair" } +func (b *localRepairBackend) Execute(_ context.Context, req agent.Request) (*agent.Result, error) { + return &agent.Result{Success: true, Patches: map[string][]byte{"output.md": []byte(b.body)}}, nil +} + +type parentEscapeBackend struct{ body string } + +func (b *parentEscapeBackend) Name() string { return "parent-escape" } +func (b *parentEscapeBackend) Execute(_ context.Context, req agent.Request) (*agent.Result, error) { + parent := filepath.Clean(filepath.Join(req.WorkDir, "..", "..", "output.md")) + _ = os.WriteFile(parent, []byte(b.body), 0o644) + return &agent.Result{ + Success: true, + Patches: map[string][]byte{"../../output.md": []byte(b.body)}, + }, nil +} + +type bothWriteBackend struct{ local, parent string } + +func (b *bothWriteBackend) Name() string { return "both-write" } +func (b *bothWriteBackend) Execute(_ context.Context, req agent.Request) (*agent.Result, error) { + parent := filepath.Clean(filepath.Join(req.WorkDir, "..", "..", "output.md")) + _ = os.WriteFile(parent, []byte(b.parent), 0o644) + return &agent.Result{ + Success: true, + Patches: map[string][]byte{"output.md": []byte(b.local)}, + }, nil +} + +func TestEnsureQualityOrRepair_LocalTargetPromotesAndRevalidates(t *testing.T) { + workDir := prepareRepairWS(t) + driver := squad.NewWorkflowDriver(squad.NewWorkspaceEngine()) + gate := squad.NewQualityGate() + report, err := gate.EnsureQualityOrRepair(context.Background(), workDir, "intake", driver, &localRepairBackend{body: repairGoodDraft}, 1, squad.ExecuteOptions{ + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("EnsureQualityOrRepair: %v", err) + } + if report == nil || !report.Passed { + t.Fatalf("expected revalidate PASS after local repair, got %+v", report) + } + root, err := os.ReadFile(filepath.Join(workDir, "output.md")) + if err != nil || !strings.Contains(string(root), "详细方案") { + t.Fatalf("root output.md must be overwritten from local repair, got %q", root) + } + task, err := os.ReadFile(filepath.Join(workDir, "tasks", "repair_1", "TASK.md")) + if err != nil { + t.Fatal(err) + } + if squad.TaskMarkdownEscapesParent(string(task)) { + t.Fatalf("repair TASK.md must not contain ../ :\n%s", task) + } + if !strings.Contains(string(task), "source_output.md") { + t.Fatalf("repair TASK.md must cite local source_output.md:\n%s", task) + } + src, err := os.ReadFile(filepath.Join(workDir, "tasks", "repair_1", squad.RepairSourceOutputName)) + if err != nil || !strings.Contains(string(src), "不做展开") { + t.Fatalf("source_output.md should keep the failing draft, got %q", src) + } +} + +func TestEnsureQualityOrRepair_ParentEscapeRestoresAndFailsClosed(t *testing.T) { + workDir := prepareRepairWS(t) + orig, _ := os.ReadFile(filepath.Join(workDir, "output.md")) + driver := squad.NewWorkflowDriver(squad.NewWorkspaceEngine()) + gate := squad.NewQualityGate() + report, err := gate.EnsureQualityOrRepair(context.Background(), workDir, "intake", driver, &parentEscapeBackend{body: repairGoodDraft}, 1, squad.ExecuteOptions{ + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatalf("EnsureQualityOrRepair: %v", err) + } + if report == nil || report.Passed { + t.Fatalf("escaped parent write must not count as success: %+v", report) + } + if len(report.MissingSections) == 0 { + t.Fatalf("must keep original quality_failed missing sections, got %+v", report) + } + got, _ := os.ReadFile(filepath.Join(workDir, "output.md")) + if string(got) != string(orig) { + t.Fatalf("parent output.md must be restored to pre-repair bytes, got %q", got) + } + if _, err := os.Stat(filepath.Join(workDir, "tasks", "repair_1", "output.md")); err == nil { + t.Fatal("must not invent a local repair target") + } + audit, _ := os.ReadFile(filepath.Join(workDir, "gov_audit.jsonl")) + if !strings.Contains(string(audit), squad.GovAuditContainment) { + t.Fatalf("expected containment audit, got %s", audit) + } +} + +func TestEnsureQualityOrRepair_BothChangedPromotesLocalAfterRestore(t *testing.T) { + workDir := prepareRepairWS(t) + driver := squad.NewWorkflowDriver(squad.NewWorkspaceEngine()) + gate := squad.NewQualityGate() + parentDecoy := repairGoodDraft + "\n\n## 越界父级不应被采用\n" + report, err := gate.EnsureQualityOrRepair(context.Background(), workDir, "intake", driver, &bothWriteBackend{ + local: repairGoodDraft, + parent: parentDecoy, + }, 1, squad.ExecuteOptions{Timeout: 5 * time.Second}) + if err != nil { + t.Fatalf("EnsureQualityOrRepair: %v", err) + } + if report == nil || !report.Passed { + t.Fatalf("trusted local target should still pass after restoring parent: %+v", report) + } + root, _ := os.ReadFile(filepath.Join(workDir, "output.md")) + if strings.Contains(string(root), "越界父级不应被采用") { + t.Fatalf("must not keep escaped parent bytes: %s", root) + } + if !strings.Contains(string(root), "详细方案") { + t.Fatalf("must promote local repair body, got %s", root) + } + audit, _ := os.ReadFile(filepath.Join(workDir, "gov_audit.jsonl")) + if !strings.Contains(string(audit), squad.GovAuditContainment) { + t.Fatalf("both-changed must still record containment, got %s", audit) + } +} + +func TestEnsureQualityOrRepair_RepairBudgetNotRegressed(t *testing.T) { + workDir := prepareRepairWS(t) + driver := squad.NewWorkflowDriver(squad.NewWorkspaceEngine()) + gate := squad.NewQualityGate() + _, err := gate.EnsureQualityOrRepair(context.Background(), workDir, "intake", driver, &parentEscapeBackend{body: repairGoodDraft}, 1, squad.ExecuteOptions{ + Timeout: 5 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + ledger := squad.LoadOrCreateLedger(workDir, squad.DefaultBudgetPolicy()) + if ledger.RepairRounds != 1 { + t.Fatalf("containment fail still consumes one repair round, got %d", ledger.RepairRounds) + } +} + +func TestAddStep_SnapshotLayoutCopiesNeedsLocally(t *testing.T) { + engine := squad.NewWorkspaceEngine() + ws, err := engine.PrepareWorkspace(squad.WorkspacePrepareRequest{ + WorkDir: t.TempDir(), + TaskKey: "intake", + StageTitle: "用户初始需求收集表", + TemplateContent: repairTemplate, + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(ws.WorkDir, "output.md"), []byte(repairBadDraft), 0o644); err != nil { + t.Fatal(err) + } + driver := squad.NewWorkflowDriver(engine) + err = driver.AddStep(ws.WorkDir, squad.StepSpec{ + ID: "step_repair_1", + Role: "writer", + Title: "repair", + SubWorkspace: "tasks/repair_1", + Mode: "execute", + Needs: []string{"output.md"}, + InputLayout: squad.InputLayoutSnapshot, + }) + if err != nil { + t.Fatal(err) + } + sub := filepath.Join(ws.WorkDir, "tasks", "repair_1") + task, _ := os.ReadFile(filepath.Join(sub, "TASK.md")) + if squad.TaskMarkdownEscapesParent(string(task)) { + t.Fatalf("AddStep snapshot TASK.md has ../:\n%s", task) + } + if _, err := os.Lstat(filepath.Join(sub, "template.md")); err != nil { + t.Fatal(err) + } + info, err := os.Lstat(filepath.Join(sub, "template.md")) + if err != nil || !info.Mode().IsRegular() { + t.Fatalf("snapshot template.md must be a regular file: %v", err) + } + if _, err := os.Stat(filepath.Join(sub, squad.RepairSourceOutputName)); err != nil { + t.Fatal(err) + } +} diff --git a/internal/squad/repair_workspace.go b/internal/squad/repair_workspace.go new file mode 100644 index 00000000..fb91c6f8 --- /dev/null +++ b/internal/squad/repair_workspace.go @@ -0,0 +1,270 @@ +package squad + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + InputLayoutSnapshot = "snapshot" + RepairSourceOutputName = "source_output.md" + RepairTargetName = "output.md" + GovAuditContainment = "repair_containment_violation" +) + +// WorkspaceInputSnapshot is a byte-accurate copy of authoritative parent inputs +// taken before a contained repair step runs. +type WorkspaceInputSnapshot struct { + Output []byte + Template []byte + Blackboard []byte + OutputOK bool + TemplateOK bool + BlackboardOK bool +} + +func digestBytes(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:8]) +} + +func SnapshotWorkspaceInputs(wsDir string) WorkspaceInputSnapshot { + var snap WorkspaceInputSnapshot + if b, err := os.ReadFile(filepath.Join(wsDir, "output.md")); err == nil { + snap.Output, snap.OutputOK = append([]byte(nil), b...), true + } + if b, err := os.ReadFile(filepath.Join(wsDir, "template.md")); err == nil { + snap.Template, snap.TemplateOK = append([]byte(nil), b...), true + } + if b, err := os.ReadFile(filepath.Join(wsDir, "blackboard.json")); err == nil { + snap.Blackboard, snap.BlackboardOK = append([]byte(nil), b...), true + } + return snap +} + +func RestoreWorkspaceInputs(wsDir string, snap WorkspaceInputSnapshot) error { + if snap.OutputOK { + if err := writeRegularFile(filepath.Join(wsDir, "output.md"), snap.Output); err != nil { + return err + } + } + if snap.TemplateOK { + if err := writeRegularFile(filepath.Join(wsDir, "template.md"), snap.Template); err != nil { + return err + } + } + if snap.BlackboardOK { + if err := writeRegularFile(filepath.Join(wsDir, "blackboard.json"), snap.Blackboard); err != nil { + return err + } + } + return nil +} + +func ParentInputsMutated(wsDir string, snap WorkspaceInputSnapshot) []string { + var changed []string + if snap.OutputOK { + cur, err := os.ReadFile(filepath.Join(wsDir, "output.md")) + if err != nil || !bytes.Equal(cur, snap.Output) { + changed = append(changed, "output.md") + } + } + if snap.TemplateOK { + cur, err := os.ReadFile(filepath.Join(wsDir, "template.md")) + if err != nil || !bytes.Equal(cur, snap.Template) { + changed = append(changed, "template.md") + } + } + if snap.BlackboardOK { + cur, err := os.ReadFile(filepath.Join(wsDir, "blackboard.json")) + if err != nil || !bytes.Equal(cur, snap.Blackboard) { + changed = append(changed, "blackboard.json") + } + } + return changed +} + +func writeRegularFile(dst string, data []byte) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + _ = os.Remove(dst) + return os.WriteFile(dst, data, 0o644) +} + +func copyRegularFile(src, dst string) error { + data, err := os.ReadFile(src) + if err != nil { + return err + } + return writeRegularFile(dst, data) +} + +// LocalNeedSnapshotName maps a parent Need onto a local filename. +// Root/step output.md must not occupy the target slot (that would fake a deliverable). +func LocalNeedSnapshotName(need, targetDeliverable string) string { + base := filepath.Base(filepath.FromSlash(need)) + if base == "" || base == "." { + return "" + } + if targetDeliverable == "" { + targetDeliverable = RepairTargetName + } + if base == targetDeliverable { + return RepairSourceOutputName + } + return base +} + +func TaskMarkdownEscapesParent(task string) bool { + return strings.Contains(task, "../") +} + +// TrustedLocalDeliverable returns a non-empty regular file inside subDir. +// Symlinks and paths that escape the sub-workspace are rejected. +func TrustedLocalDeliverable(subDir, name string) ([]byte, bool) { + if name == "" || strings.Contains(name, "..") { + return nil, false + } + p := filepath.Join(subDir, filepath.FromSlash(name)) + info, err := os.Lstat(p) + if err != nil || !info.Mode().IsRegular() { + return nil, false + } + abs, err := filepath.Abs(p) + if err != nil { + return nil, false + } + root, err := filepath.Abs(subDir) + if err != nil { + return nil, false + } + rel, err := filepath.Rel(root, abs) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return nil, false + } + data, err := os.ReadFile(p) + if err != nil || len(bytes.TrimSpace(data)) == 0 { + return nil, false + } + return data, true +} + +// SnapshotInputsIntoSubdir copies parent template/blackboard/Needs as regular +// local files. output.md needs become source_output.md so the runner target stays empty. +func SnapshotInputsIntoSubdir(wsDir, subDir string, needs []string, targetDeliverable string) error { + if targetDeliverable == "" { + targetDeliverable = RepairTargetName + } + if err := os.MkdirAll(subDir, 0o755); err != nil { + return err + } + if _, err := os.Stat(filepath.Join(wsDir, "template.md")); err == nil { + if err := copyRegularFile(filepath.Join(wsDir, "template.md"), filepath.Join(subDir, "template.md")); err != nil { + return err + } + } + if _, err := os.Stat(filepath.Join(wsDir, "blackboard.json")); err == nil { + if err := copyRegularFile(filepath.Join(wsDir, "blackboard.json"), filepath.Join(subDir, "blackboard.json")); err != nil { + return err + } + } + seen := map[string]struct{}{"template.md": {}, "blackboard.json": {}} + for _, need := range needs { + local := LocalNeedSnapshotName(need, targetDeliverable) + if local == "" || local == targetDeliverable { + continue + } + if _, dup := seen[local]; dup { + continue + } + src := filepath.Join(wsDir, filepath.FromSlash(need)) + if _, err := os.Stat(src); err != nil { + continue + } + if err := copyRegularFile(src, filepath.Join(subDir, local)); err != nil { + return err + } + seen[local] = struct{}{} + } + return nil +} + +func BuildContainedTaskMarkdown(step StepSpec, deliverableName string) string { + if deliverableName == "" { + deliverableName = RepairTargetName + } + var b strings.Builder + b.WriteString(fmt.Sprintf("# 任务执行指令: %s\n\n", step.Title)) + b.WriteString(fmt.Sprintf("- **步骤 ID**: `%s`\n", step.ID)) + b.WriteString(fmt.Sprintf("- **分配角色**: `%s`\n", step.Role)) + b.WriteString(fmt.Sprintf("- **执行模式**: `%s`\n", step.Mode)) + b.WriteString(fmt.Sprintf("- **交付产物**: `%s`\n\n", deliverableName)) + b.WriteString("## 环境与输入导航\n") + b.WriteString("只阅读当前目录下的本地快照,禁止使用 `..` 或写入上级目录:\n") + b.WriteString("- `template.md`:交付模板\n") + b.WriteString("- `blackboard.json`:黑板事实(若存在)\n") + if len(step.Needs) > 0 { + b.WriteString("\n## 前序依赖产出\n") + for _, n := range step.Needs { + local := LocalNeedSnapshotName(n, deliverableName) + if local == "" { + continue + } + b.WriteString(fmt.Sprintf("- `%s`(由 `%s` 快照)\n", local, filepath.ToSlash(n))) + } + b.WriteString("\n") + } + if len(step.Accept) > 0 { + b.WriteString("## 验收标准(Checklist)\n") + for _, a := range step.Accept { + b.WriteString(fmt.Sprintf("- [ ] %s\n", a)) + } + b.WriteString("\n") + } + b.WriteString("## 交付契约与写作纪律\n") + b.WriteString(fmt.Sprintf("请在当前目录下生成完整交付物 `%s`。不要引用或改写上级文件。\n", deliverableName)) + return b.String() +} + +func WriteRepairTaskMarkdown(subDir string, attempt int, report *QualityReport, stallHinted bool) error { + var b strings.Builder + b.WriteString(fmt.Sprintf("# 质量门禁修复任务 (第 %d 次修正)\n\n", attempt)) + b.WriteString("前序提交未通过自动化质量门禁,检测到的问题如下,请针对性修复:\n") + if report != nil { + for _, v := range report.Violations { + b.WriteString(fmt.Sprintf("- ❌ %s\n", sanitizeViolationForAgent(v))) + } + } + if stallHinted { + b.WriteString("\n⚠️ 治理警告:最近两轮修复产物相似度过高,被判定为无进度空转。本轮必须针对上述违规点逐条重写对应章节,产出与之前实质不同的内容,否则任务将被强制终止。\n") + } + b.WriteString("\n只阅读当前目录本地快照,禁止使用上级路径:\n") + b.WriteString("- `source_output.md`:当前未通过门禁的初稿\n") + b.WriteString("- `template.md`:交付模板\n") + b.WriteString("- `blackboard.json`:黑板事实(若存在)\n") + b.WriteString("\n只在当前目录写入完整修复初稿 `output.md`。\n") + text := b.String() + if TaskMarkdownEscapesParent(text) { + return fmt.Errorf("repair TASK.md must not reference parent paths") + } + return writeRegularFile(filepath.Join(subDir, "TASK.md"), []byte(text)) +} + +func recordContainmentViolation(wsDir string, attempt int, mutated []string, localOK bool, snap WorkspaceInputSnapshot) { + digest := "" + if snap.OutputOK { + digest = digestBytes(snap.Output) + } + appendGovAudit(wsDir, GovAuditContainment, map[string]string{ + "attempt": fmt.Sprintf("%d", attempt), + "mutated": strings.Join(mutated, ","), + "local_target": fmt.Sprintf("%v", localOK), + "output_digest": digest, + }) +} diff --git a/internal/squad/repair_workspace_test.go b/internal/squad/repair_workspace_test.go new file mode 100644 index 00000000..802ed995 --- /dev/null +++ b/internal/squad/repair_workspace_test.go @@ -0,0 +1,122 @@ +package squad_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "chat-agent-system/internal/squad" +) + +func TestLocalNeedSnapshotName_DoesNotOccupyTarget(t *testing.T) { + if got := squad.LocalNeedSnapshotName("output.md", "output.md"); got != squad.RepairSourceOutputName { + t.Fatalf("root output.md must snapshot as source_output.md, got %q", got) + } + if got := squad.LocalNeedSnapshotName("tasks/03_review/output.md", "output.md"); got != squad.RepairSourceOutputName { + t.Fatalf("nested output.md must not occupy target, got %q", got) + } + if got := squad.LocalNeedSnapshotName("blackboard.json", "output.md"); got != "blackboard.json" { + t.Fatalf("other needs keep basename, got %q", got) + } +} + +func TestSnapshotInputsAndContainedTaskHaveNoParentRefs(t *testing.T) { + ws := t.TempDir() + if err := os.WriteFile(filepath.Join(ws, "output.md"), []byte("BAD DRAFT"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(ws, "template.md"), []byte("# tmpl\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(ws, "blackboard.json"), []byte(`{"k":1}`), 0o644); err != nil { + t.Fatal(err) + } + sub := filepath.Join(ws, "tasks", "repair_1") + if err := squad.SnapshotInputsIntoSubdir(ws, sub, []string{"output.md"}, "output.md"); err != nil { + t.Fatal(err) + } + src, err := os.ReadFile(filepath.Join(sub, squad.RepairSourceOutputName)) + if err != nil || string(src) != "BAD DRAFT" { + t.Fatalf("source snapshot: %s %v", src, err) + } + if _, err := os.Stat(filepath.Join(sub, "output.md")); err == nil { + t.Fatal("must not copy draft onto the target slot") + } + info, err := os.Lstat(filepath.Join(sub, "template.md")) + if err != nil || !info.Mode().IsRegular() { + t.Fatalf("template.md must be a regular snapshot, not a symlink: %v %v", info, err) + } + + step := squad.StepSpec{ + ID: "step_repair_1", + Role: "writer", + Title: "fix", + Mode: "execute", + Needs: []string{"output.md"}, + } + task := squad.BuildContainedTaskMarkdown(step, "output.md") + if squad.TaskMarkdownEscapesParent(task) { + t.Fatalf("contained TASK.md still has parent refs:\n%s", task) + } + if !strings.Contains(task, squad.RepairSourceOutputName) { + t.Fatalf("TASK.md must point at local source snapshot:\n%s", task) + } +} + +func TestTrustedLocalDeliverableRejectsSymlinkAndEmpty(t *testing.T) { + ws := t.TempDir() + sub := filepath.Join(ws, "tasks", "repair_1") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(ws, "output.md"), []byte("PARENT"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("..", "..", "output.md"), filepath.Join(sub, "output.md")); err != nil { + t.Fatal(err) + } + if _, ok := squad.TrustedLocalDeliverable(sub, "output.md"); ok { + t.Fatal("symlink to parent must not count as a trusted local target") + } + _ = os.Remove(filepath.Join(sub, "output.md")) + if err := os.WriteFile(filepath.Join(sub, "output.md"), []byte(" \n"), 0o644); err != nil { + t.Fatal(err) + } + if _, ok := squad.TrustedLocalDeliverable(sub, "output.md"); ok { + t.Fatal("whitespace-only local target must be rejected") + } +} + +func TestParentSnapshotRestore(t *testing.T) { + ws := t.TempDir() + orig := []byte("ORIGINAL ROOT") + if err := os.WriteFile(filepath.Join(ws, "output.md"), orig, 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(ws, "template.md"), []byte("T0"), 0o644); err != nil { + t.Fatal(err) + } + snap := squad.SnapshotWorkspaceInputs(ws) + if err := os.WriteFile(filepath.Join(ws, "output.md"), []byte("MUTATED BETTER"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(ws, "template.md"), []byte("T1"), 0o644); err != nil { + t.Fatal(err) + } + changed := squad.ParentInputsMutated(ws, snap) + if len(changed) != 2 { + t.Fatalf("expected output+template mutated, got %v", changed) + } + if err := squad.RestoreWorkspaceInputs(ws, snap); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(filepath.Join(ws, "output.md")) + if string(got) != string(orig) { + t.Fatalf("root output.md not restored, got %q", got) + } + gotT, _ := os.ReadFile(filepath.Join(ws, "template.md")) + if string(gotT) != "T0" { + t.Fatalf("template.md not restored, got %q", gotT) + } +} diff --git a/internal/squad/workspace.go b/internal/squad/workspace.go index 396d7e17..fa3a9a60 100644 --- a/internal/squad/workspace.go +++ b/internal/squad/workspace.go @@ -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)) From 01c5972ead771d7f9841296914fb2ad0d7e92666 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 20:20:13 +0000 Subject: [PATCH 2/2] fix(squad): void quality repair rounds on backend or containment failure Promote root output.md only when the runner succeeded, protected parents were untouched, and the local target is trusted. Restore errors abort instead of being ignored. Co-authored-by: Cursor --- ...ision-quality-repair-workspace-contract.md | 2 + ...ision-quality-repair-workspace-contract.md | 16 ++- ...ision-quality-repair-workspace-contract.md | 14 +- internal/squad/quality_gate.go | 37 ++++-- .../squad/quality_repair_contract_test.go | 124 ++++++++++++++++-- internal/squad/repair_workspace.go | 115 +++++++++++----- internal/squad/repair_workspace_test.go | 72 ++++++++++ 7 files changed, 317 insertions(+), 63 deletions(-) diff --git a/.doc/audit/2026-09-13-document-revision-quality-repair-workspace-contract.md b/.doc/audit/2026-09-13-document-revision-quality-repair-workspace-contract.md index e91738bb..961c2940 100644 --- a/.doc/audit/2026-09-13-document-revision-quality-repair-workspace-contract.md +++ b/.doc/audit/2026-09-13-document-revision-quality-repair-workspace-contract.md @@ -28,6 +28,7 @@ severity: high | 沙箱 | `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 正确,**不能**因此把越界根文件当成功。 @@ -52,3 +53,4 @@ severity: high - 本轮不跑真实 UI-03;不得标 E2E 通过或任务 `done` - 默认子步骤仍可用 `../../` + symlink(未改正常步骤读本地 context 的路径) +- **剩余风险(保护范围)**:containment 只快照/恢复 `output.md` / `template.md` / `blackboard.json`。模型改写其它父级文件(如 `PLAN.md`、其它 `tasks/*`)检测不到、不会回滚。创建/删除这三份受保护输入已计入 mutation。 diff --git a/.doc/ops/short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md b/.doc/ops/short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md index da2df27d..2cd985f7 100644 --- a/.doc/ops/short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md +++ b/.doc/ops/short-term/2026-09-13-document-revision-quality-repair-workspace-contract.md @@ -31,13 +31,16 @@ mutex: document-revision-quality-repair-workspace-contract 1. [x] 从 `dev@8b1a531` 开分支并登记 mutex 2. [x] 快照契约 + containment + 测试 -3. [x] gofmt / vet / `go test -short ./...` / `-race` squad+agent / settle-policy.test.mjs -4. [ ] 真实 UI-03(本轮不做) +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] 本地可信产物才覆盖根并重验 -- [x] 越界无本地目标则恢复父级并 fail-closed +- [x] 仅 `runErr==nil` ∧ 无 containment ∧ `TrustedLocalDeliverable=true` 才覆盖根并重验 +- [x] 越界(含创建/删除受保护父级)恢复后不提升,报告 FAIL +- [x] backend 失败/超时即使留下本地目标也不提升 +- [x] restore 失败立即返回,可测策略能断言错误传播 - [ ] 现场 UI-03(未跑) ## 相关链接 @@ -49,5 +52,6 @@ mutex: document-revision-quality-repair-workspace-contract - mutex:`document-revision-quality-repair-workspace-contract`(新 mutex,不与现有 in_progress 冲突) - 基线:`dev@8b1a531` -- 验证:`go test -short ./...` PASS;`go test -short -race ./internal/squad/ ./internal/agent/` PASS;`settle-policy.test.mjs` PASS -- **未跑真实 UI-03,不得标 done** +- 验证(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** diff --git a/.doc/remediation/2026-09-13-document-revision-quality-repair-workspace-contract.md b/.doc/remediation/2026-09-13-document-revision-quality-repair-workspace-contract.md index 7519ca69..aeea954f 100644 --- a/.doc/remediation/2026-09-13-document-revision-quality-repair-workspace-contract.md +++ b/.doc/remediation/2026-09-13-document-revision-quality-repair-workspace-contract.md @@ -17,7 +17,7 @@ updated: 2026-09-13 - 修复步只消费本地快照、只写当前目录 `output.md` - 越界改父级且无可信本地目标:恢复运行前字节并 fail-closed -- 仅当本地 `repair/output.md` 非空且可信时覆盖根文件并重新 Validate +- 仅当 `runErr==nil` **且** 无受保护父级越界 **且** `TrustedLocalDeliverable=true` 时覆盖根文件并重新 Validate - 重复/预算语义不回归;runner missing-target 仍 fail-closed ## 分波次计划 @@ -32,15 +32,19 @@ updated: 2026-09-13 1. **快照而非 `../../`**:`AddStep` 对 repair 使用 `InputLayoutSnapshot`。把 `output.md` 复制为 `source_output.md`(绝不占目标槽),`template.md` / `blackboard.json` 复制为常规文件(去 symlink)。 2. **TASK.md**:质量说明只引用本地名,禁止 `../`。 3. **运行前快照**:根 `output.md` / `template.md` / `blackboard.json` 字节。 -4. **containment**:父级任一输入被改 → 先恢复运行前字节并写 `gov_audit.jsonl`。无可信本地目标则 break(保持原报告)。父级与本地同时改:恢复父级后**只提升本地**目标。 -5. **成功路径**:`TrustedLocalDeliverable`(常规文件、非空、不逃逸)→ 覆盖根 `output.md` → `Validate`。 +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] 原稿缺章节 → 本地修复产物补齐 → 根覆盖 → revalidate PASS(仅三重谓词全绿) - [x] 只越界改父级、缺本地目标 → 父级恢复、FAIL + containment 审计 -- [x] 父级与本地同时改:恢复父级、提升本地 +- [x] 父级与本地同时改:恢复父级、**不提升**本地、报告 FAIL +- [x] backend 写有效本地目标后返回 error:0 次提升、根保持运行前字节、FAIL +- [x] 初始缺失的受保护父级被创建:视为 mutation,恢复后删除、不提升 +- [x] restore 失败立即返回错误并写 `repair_restore_failure` 审计 - [x] TASK.md / 工作区构造不含 `../` 输入引用 - [x] 修复预算仍消耗一轮;missing-target fail-closed - [ ] 真实 UI-03(本轮不做) diff --git a/internal/squad/quality_gate.go b/internal/squad/quality_gate.go index af93a9a8..59fc0152 100644 --- a/internal/squad/quality_gate.go +++ b/internal/squad/quality_gate.go @@ -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, @@ -621,24 +622,34 @@ func (q *QualityGate) EnsureQualityOrRepair( repairedBytes, localOK := TrustedLocalDeliverable(repairAbs, RepairTargetName) mutated := ParentInputsMutated(wsDir, parentSnap) if len(mutated) > 0 { - _ = RestoreWorkspaceInputs(wsDir, parentSnap) recordContainmentViolation(wsDir, attempt, mutated, localOK, parentSnap) - if st := stream.FromContext(ctx); st != nil { - st.EmitPhase("governance", "修复 containment 违规: 父级输入已被越界改写,已恢复运行前字节", "taskgov", 0, 0, 0) - } - } - - if !localOK { - if runErr != nil { - appendGovAudit(wsDir, "repair_local_target_missing", map[string]string{ + if rerr := restoreWorkspaceInputsFn(wsDir, parentSnap); rerr != nil { + appendGovAudit(wsDir, GovAuditRestoreFailure, map[string]string{ "attempt": fmt.Sprintf("%d", attempt), - "error": runErr.Error(), + "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) } - break } - // 仅当本地 repair/output.md 非空且可信时覆盖根 output.md,再重新 Validate。 + 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 { diff --git a/internal/squad/quality_repair_contract_test.go b/internal/squad/quality_repair_contract_test.go index 6744ef0b..c386a7b0 100644 --- a/internal/squad/quality_repair_contract_test.go +++ b/internal/squad/quality_repair_contract_test.go @@ -2,6 +2,7 @@ package squad_test import ( "context" + "fmt" "os" "path/filepath" "strings" @@ -74,6 +75,31 @@ func (b *bothWriteBackend) Execute(_ context.Context, req agent.Request) (*agent }, 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()) @@ -137,8 +163,9 @@ func TestEnsureQualityOrRepair_ParentEscapeRestoresAndFailsClosed(t *testing.T) } } -func TestEnsureQualityOrRepair_BothChangedPromotesLocalAfterRestore(t *testing.T) { +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" @@ -149,19 +176,100 @@ func TestEnsureQualityOrRepair_BothChangedPromotesLocalAfterRestore(t *testing.T if err != nil { t.Fatalf("EnsureQualityOrRepair: %v", err) } - if report == nil || !report.Passed { - t.Fatalf("trusted local target should still pass after restoring parent: %+v", report) + 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 strings.Contains(string(root), "越界父级不应被采用") { - t.Fatalf("must not keep escaped parent bytes: %s", root) + if string(root) != string(orig) { + t.Fatalf("root must stay pre-repair bytes, got %q", root) } - if !strings.Contains(string(root), "详细方案") { - t.Fatalf("must promote local repair body, got %s", 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 still record containment, got %s", audit) + 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) } } diff --git a/internal/squad/repair_workspace.go b/internal/squad/repair_workspace.go index fb91c6f8..b20f9710 100644 --- a/internal/squad/repair_workspace.go +++ b/internal/squad/repair_workspace.go @@ -11,12 +11,21 @@ import ( ) const ( - InputLayoutSnapshot = "snapshot" - RepairSourceOutputName = "source_output.md" - RepairTargetName = "output.md" - GovAuditContainment = "repair_containment_violation" + 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 { @@ -47,48 +56,92 @@ func SnapshotWorkspaceInputs(wsDir string) WorkspaceInputSnapshot { return snap } -func RestoreWorkspaceInputs(wsDir string, snap WorkspaceInputSnapshot) error { - if snap.OutputOK { - if err := writeRegularFile(filepath.Join(wsDir, "output.md"), snap.Output); err != nil { - return err +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 snap.TemplateOK { - if err := writeRegularFile(filepath.Join(wsDir, "template.md"), snap.Template); err != nil { - return err - } - } - if snap.BlackboardOK { - if err := writeRegularFile(filepath.Join(wsDir, "blackboard.json"), snap.Blackboard); err != nil { - return err + 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 snap.OutputOK { - cur, err := os.ReadFile(filepath.Join(wsDir, "output.md")) - if err != nil || !bytes.Equal(cur, snap.Output) { - changed = append(changed, "output.md") - } + if parentFileMutated(filepath.Join(wsDir, "output.md"), snap.OutputOK, snap.Output) { + changed = append(changed, "output.md") } - if snap.TemplateOK { - cur, err := os.ReadFile(filepath.Join(wsDir, "template.md")) - if err != nil || !bytes.Equal(cur, snap.Template) { - changed = append(changed, "template.md") - } + if parentFileMutated(filepath.Join(wsDir, "template.md"), snap.TemplateOK, snap.Template) { + changed = append(changed, "template.md") } - if snap.BlackboardOK { - cur, err := os.ReadFile(filepath.Join(wsDir, "blackboard.json")) - if err != nil || !bytes.Equal(cur, snap.Blackboard) { - changed = append(changed, "blackboard.json") - } + 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 diff --git a/internal/squad/repair_workspace_test.go b/internal/squad/repair_workspace_test.go index 802ed995..ff90818e 100644 --- a/internal/squad/repair_workspace_test.go +++ b/internal/squad/repair_workspace_test.go @@ -88,6 +88,78 @@ func TestTrustedLocalDeliverableRejectsSymlinkAndEmpty(t *testing.T) { } } +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")