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")