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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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。
|
||||
|
||||
+10
-6
@@ -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**
|
||||
|
||||
@@ -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(本轮不做)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,17 @@ const (
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user