Files
Agent-System/internal/squad/repair_workspace.go
T
weichenandCursor 01c5972ead 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>
2026-09-13 20:20:13 +00:00

324 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package squad
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
InputLayoutSnapshot = "snapshot"
RepairSourceOutputName = "source_output.md"
RepairTargetName = "output.md"
GovAuditContainment = "repair_containment_violation"
GovAuditRestoreFailure = "repair_restore_failure"
RepairPromoteOK = "ok"
RepairDiscardRunError = "run_error"
RepairDiscardContainment = "containment"
RepairDiscardNoLocal = "missing_local"
)
// ProtectedParentInputs are the only parent files snapshotted for repair
// containment. Other workspace files are residual risk if the model writes them.
var ProtectedParentInputs = []string{"output.md", "template.md", "blackboard.json"}
// WorkspaceInputSnapshot is a byte-accurate copy of authoritative parent inputs
// taken before a contained repair step runs.
type WorkspaceInputSnapshot struct {
Output []byte
Template []byte
Blackboard []byte
OutputOK bool
TemplateOK bool
BlackboardOK bool
}
func digestBytes(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:8])
}
func SnapshotWorkspaceInputs(wsDir string) WorkspaceInputSnapshot {
var snap WorkspaceInputSnapshot
if b, err := os.ReadFile(filepath.Join(wsDir, "output.md")); err == nil {
snap.Output, snap.OutputOK = append([]byte(nil), b...), true
}
if b, err := os.ReadFile(filepath.Join(wsDir, "template.md")); err == nil {
snap.Template, snap.TemplateOK = append([]byte(nil), b...), true
}
if b, err := os.ReadFile(filepath.Join(wsDir, "blackboard.json")); err == nil {
snap.Blackboard, snap.BlackboardOK = append([]byte(nil), b...), true
}
return snap
}
func restoreProtectedFile(path string, existed bool, data []byte) error {
_, statErr := os.Lstat(path)
nowExists := statErr == nil
if existed {
if err := writeRegularFile(path, data); err != nil {
return fmt.Errorf("restore %s: %w", filepath.Base(path), err)
}
return nil
}
if nowExists {
if err := os.RemoveAll(path); err != nil {
return fmt.Errorf("restore: remove created %s: %w", filepath.Base(path), err)
}
}
return nil
}
// restoreWorkspaceInputsFn is the restore strategy used by EnsureQualityOrRepair.
// Tests replace it via OverrideRestoreWorkspaceInputsForTest.
var restoreWorkspaceInputsFn = RestoreWorkspaceInputs
// OverrideRestoreWorkspaceInputsForTest swaps the restore strategy and returns a reset func.
func OverrideRestoreWorkspaceInputsForTest(fn func(string, WorkspaceInputSnapshot) error) func() {
prev := restoreWorkspaceInputsFn
if fn == nil {
restoreWorkspaceInputsFn = RestoreWorkspaceInputs
} else {
restoreWorkspaceInputsFn = fn
}
return func() { restoreWorkspaceInputsFn = prev }
}
func RestoreWorkspaceInputs(wsDir string, snap WorkspaceInputSnapshot) error {
if err := restoreProtectedFile(filepath.Join(wsDir, "output.md"), snap.OutputOK, snap.Output); err != nil {
return err
}
if err := restoreProtectedFile(filepath.Join(wsDir, "template.md"), snap.TemplateOK, snap.Template); err != nil {
return err
}
if err := restoreProtectedFile(filepath.Join(wsDir, "blackboard.json"), snap.BlackboardOK, snap.Blackboard); err != nil {
return err
}
return nil
}
func parentFileMutated(path string, existed bool, data []byte) bool {
cur, err := os.ReadFile(path)
nowExists := err == nil
if existed != nowExists {
return true
}
if existed && !bytes.Equal(cur, data) {
return true
}
return false
}
func ParentInputsMutated(wsDir string, snap WorkspaceInputSnapshot) []string {
var changed []string
if parentFileMutated(filepath.Join(wsDir, "output.md"), snap.OutputOK, snap.Output) {
changed = append(changed, "output.md")
}
if parentFileMutated(filepath.Join(wsDir, "template.md"), snap.TemplateOK, snap.Template) {
changed = append(changed, "template.md")
}
if parentFileMutated(filepath.Join(wsDir, "blackboard.json"), snap.BlackboardOK, snap.Blackboard) {
changed = append(changed, "blackboard.json")
}
return changed
}
// DecideRepairPromote is the only gate that may overlay root output.md.
// runErr, any protected-parent mutation, or an untrusted local target each void the round.
func DecideRepairPromote(runErr error, mutated []string, localOK bool) (promote bool, reason string) {
if runErr != nil {
return false, RepairDiscardRunError
}
if len(mutated) > 0 {
return false, RepairDiscardContainment
}
if !localOK {
return false, RepairDiscardNoLocal
}
return true, RepairPromoteOK
}
func writeRegularFile(dst string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
_ = os.Remove(dst)
return os.WriteFile(dst, data, 0o644)
}
func copyRegularFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return writeRegularFile(dst, data)
}
// LocalNeedSnapshotName maps a parent Need onto a local filename.
// Root/step output.md must not occupy the target slot (that would fake a deliverable).
func LocalNeedSnapshotName(need, targetDeliverable string) string {
base := filepath.Base(filepath.FromSlash(need))
if base == "" || base == "." {
return ""
}
if targetDeliverable == "" {
targetDeliverable = RepairTargetName
}
if base == targetDeliverable {
return RepairSourceOutputName
}
return base
}
func TaskMarkdownEscapesParent(task string) bool {
return strings.Contains(task, "../")
}
// TrustedLocalDeliverable returns a non-empty regular file inside subDir.
// Symlinks and paths that escape the sub-workspace are rejected.
func TrustedLocalDeliverable(subDir, name string) ([]byte, bool) {
if name == "" || strings.Contains(name, "..") {
return nil, false
}
p := filepath.Join(subDir, filepath.FromSlash(name))
info, err := os.Lstat(p)
if err != nil || !info.Mode().IsRegular() {
return nil, false
}
abs, err := filepath.Abs(p)
if err != nil {
return nil, false
}
root, err := filepath.Abs(subDir)
if err != nil {
return nil, false
}
rel, err := filepath.Rel(root, abs)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return nil, false
}
data, err := os.ReadFile(p)
if err != nil || len(bytes.TrimSpace(data)) == 0 {
return nil, false
}
return data, true
}
// SnapshotInputsIntoSubdir copies parent template/blackboard/Needs as regular
// local files. output.md needs become source_output.md so the runner target stays empty.
func SnapshotInputsIntoSubdir(wsDir, subDir string, needs []string, targetDeliverable string) error {
if targetDeliverable == "" {
targetDeliverable = RepairTargetName
}
if err := os.MkdirAll(subDir, 0o755); err != nil {
return err
}
if _, err := os.Stat(filepath.Join(wsDir, "template.md")); err == nil {
if err := copyRegularFile(filepath.Join(wsDir, "template.md"), filepath.Join(subDir, "template.md")); err != nil {
return err
}
}
if _, err := os.Stat(filepath.Join(wsDir, "blackboard.json")); err == nil {
if err := copyRegularFile(filepath.Join(wsDir, "blackboard.json"), filepath.Join(subDir, "blackboard.json")); err != nil {
return err
}
}
seen := map[string]struct{}{"template.md": {}, "blackboard.json": {}}
for _, need := range needs {
local := LocalNeedSnapshotName(need, targetDeliverable)
if local == "" || local == targetDeliverable {
continue
}
if _, dup := seen[local]; dup {
continue
}
src := filepath.Join(wsDir, filepath.FromSlash(need))
if _, err := os.Stat(src); err != nil {
continue
}
if err := copyRegularFile(src, filepath.Join(subDir, local)); err != nil {
return err
}
seen[local] = struct{}{}
}
return nil
}
func BuildContainedTaskMarkdown(step StepSpec, deliverableName string) string {
if deliverableName == "" {
deliverableName = RepairTargetName
}
var b strings.Builder
b.WriteString(fmt.Sprintf("# 任务执行指令: %s\n\n", step.Title))
b.WriteString(fmt.Sprintf("- **步骤 ID**: `%s`\n", step.ID))
b.WriteString(fmt.Sprintf("- **分配角色**: `%s`\n", step.Role))
b.WriteString(fmt.Sprintf("- **执行模式**: `%s`\n", step.Mode))
b.WriteString(fmt.Sprintf("- **交付产物**: `%s`\n\n", deliverableName))
b.WriteString("## 环境与输入导航\n")
b.WriteString("只阅读当前目录下的本地快照,禁止使用 `..` 或写入上级目录:\n")
b.WriteString("- `template.md`:交付模板\n")
b.WriteString("- `blackboard.json`:黑板事实(若存在)\n")
if len(step.Needs) > 0 {
b.WriteString("\n## 前序依赖产出\n")
for _, n := range step.Needs {
local := LocalNeedSnapshotName(n, deliverableName)
if local == "" {
continue
}
b.WriteString(fmt.Sprintf("- `%s`(由 `%s` 快照)\n", local, filepath.ToSlash(n)))
}
b.WriteString("\n")
}
if len(step.Accept) > 0 {
b.WriteString("## 验收标准(Checklist)\n")
for _, a := range step.Accept {
b.WriteString(fmt.Sprintf("- [ ] %s\n", a))
}
b.WriteString("\n")
}
b.WriteString("## 交付契约与写作纪律\n")
b.WriteString(fmt.Sprintf("请在当前目录下生成完整交付物 `%s`。不要引用或改写上级文件。\n", deliverableName))
return b.String()
}
func WriteRepairTaskMarkdown(subDir string, attempt int, report *QualityReport, stallHinted bool) error {
var b strings.Builder
b.WriteString(fmt.Sprintf("# 质量门禁修复任务 (第 %d 次修正)\n\n", attempt))
b.WriteString("前序提交未通过自动化质量门禁,检测到的问题如下,请针对性修复:\n")
if report != nil {
for _, v := range report.Violations {
b.WriteString(fmt.Sprintf("- ❌ %s\n", sanitizeViolationForAgent(v)))
}
}
if stallHinted {
b.WriteString("\n⚠️ 治理警告:最近两轮修复产物相似度过高,被判定为无进度空转。本轮必须针对上述违规点逐条重写对应章节,产出与之前实质不同的内容,否则任务将被强制终止。\n")
}
b.WriteString("\n只阅读当前目录本地快照,禁止使用上级路径:\n")
b.WriteString("- `source_output.md`:当前未通过门禁的初稿\n")
b.WriteString("- `template.md`:交付模板\n")
b.WriteString("- `blackboard.json`:黑板事实(若存在)\n")
b.WriteString("\n只在当前目录写入完整修复初稿 `output.md`。\n")
text := b.String()
if TaskMarkdownEscapesParent(text) {
return fmt.Errorf("repair TASK.md must not reference parent paths")
}
return writeRegularFile(filepath.Join(subDir, "TASK.md"), []byte(text))
}
func recordContainmentViolation(wsDir string, attempt int, mutated []string, localOK bool, snap WorkspaceInputSnapshot) {
digest := ""
if snap.OutputOK {
digest = digestBytes(snap.Output)
}
appendGovAudit(wsDir, GovAuditContainment, map[string]string{
"attempt": fmt.Sprintf("%d", attempt),
"mutated": strings.Join(mutated, ","),
"local_target": fmt.Sprintf("%v", localOK),
"output_digest": digest,
})
}