From b7cfb282170c71425f3097372bd9cd90a4989bc4 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 20:15:54 +0000 Subject: [PATCH] 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))