项目文件夹

文件
2026-07-13 13:00:08 +08:00

191 行
6.7 KiB
Go

package agent
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"time"
)
// Recovery-branch garbage collection. Conflict recovery forks a copy of the
// in-memory transcript whenever a save conflicts (#5993); the triggers are
// fixed, but every fork that ever happened still sits in the session list
// until the user trashes it by hand. Most of them preserve nothing: the
// original session went on to contain everything the fork saved. Those — and
// only those — are safe to reclaim automatically.
// RecoveryGCGracePeriod is how long a reclaimable recovery branch must sit
// idle before GC may collect it. A fresh fork is part of an active conflict
// flow — the user may be comparing it against the original right now.
const RecoveryGCGracePeriod = 24 * time.Hour
// ErrRecoveryBranchNotCovered means the branch cannot currently be proven
// redundant with its parent. Destructive callers must preserve it.
var ErrRecoveryBranchNotCovered = errors.New("recovery branch is not covered by its parent")
// SessionLeaseHeld reports whether ANY live runtime — this process included —
// holds the session's write lease. SessionLeaseHeldByOtherRuntime deliberately
// answers false for the current process; GC needs the stricter question, since
// a branch open in one of our own tabs is just as much in use.
func SessionLeaseHeld(path string) bool {
if strings.TrimSpace(path) == "" {
return false
}
if _, ok := sessionLeaseOwners.Load(canonicalSessionSavePath(path)); ok {
return true
}
return SessionLeaseHeldByOtherRuntime(path)
}
// RecoveryBranchCoveredByParent reports whether a conflict-recovery branch
// preserves no content that is absent from its parent. It deliberately reads
// both transcripts instead of trusting listing sidecars: stale metadata must
// never authorize hiding, migration skipping, bulk trash, or permanent purge.
// Missing/corrupt metadata, a changed branch, or a missing/diverged parent are
// all treated conservatively as not covered.
func RecoveryBranchCoveredByParent(path, parentDir string) bool {
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok || !meta.Recovered || strings.TrimSpace(meta.RecoveryDigest) == "" {
return false
}
return recoveryBranchCoveredByParent(path, parentDir, meta)
}
// TryAcquireRecoveryParentGuard verifies that a recovery branch is covered by
// its parent while holding the parent's save and lease locks. The caller must
// keep the returned guard until permanent deletion finishes, then Release it.
// If the parent is open or being rewritten, acquisition fails without waiting
// so bulk cleanup preserves the branch and can be retried later.
func TryAcquireRecoveryParentGuard(path, parentDir string) (*SessionRemovalGuard, error) {
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok || !meta.Recovered || strings.TrimSpace(meta.RecoveryDigest) == "" {
return nil, ErrRecoveryBranchNotCovered
}
parentID := strings.TrimSpace(meta.ParentID)
if parentID == "" {
return nil, ErrRecoveryBranchNotCovered
}
parentDir = strings.TrimSpace(parentDir)
if parentDir == "" {
parentDir = filepath.Dir(path)
}
parentPath := filepath.Join(parentDir, parentID+".jsonl")
if parentPath == path || !IsVisibleSession(parentPath) {
return nil, ErrRecoveryBranchNotCovered
}
guard, err := TryAcquireSessionRemovalGuard(parentPath)
if err != nil {
return nil, err
}
if !recoveryBranchCoveredByParent(path, parentDir, meta) {
guard.Release()
return nil, ErrRecoveryBranchNotCovered
}
return guard, nil
}
func recoveryBranchCoveredByParent(path, parentDir string, meta BranchMeta) bool {
parentID := strings.TrimSpace(meta.ParentID)
if parentID == "" {
return false
}
branch, err := LoadSession(path)
if err != nil || branch == nil {
return false
}
branchMsgs := branch.Snapshot()
branchDigest, err := digestSessionMessages(branchMsgs)
if err != nil || digestString(branchDigest) != strings.TrimSpace(meta.RecoveryDigest) {
// Continued on (or undigestable): this is someone's conversation now.
return false
}
parentDir = strings.TrimSpace(parentDir)
if parentDir == "" {
parentDir = filepath.Dir(path)
}
parentPath := filepath.Join(parentDir, parentID+".jsonl")
if parentPath == path || !IsVisibleSession(parentPath) {
return false
}
parent, err := LoadSession(parentPath)
if err != nil || parent == nil {
return false
}
parentMsgs := parent.Snapshot()
parentDigest, err := digestSessionMessages(parentMsgs)
if err != nil {
return false
}
return bytes.Equal(parentDigest[:], branchDigest[:]) ||
messagesHavePrefix(parentMsgs, branchMsgs) ||
messagesHavePrefixWithCompatibleSystem(parentMsgs, branchMsgs)
}
// ReclaimableRecoveryBranches scans dir for conflict-recovery branches that
// are safe to dispose of. Every condition must hold — when in doubt the branch
// stays, because a recovery branch exists precisely to prevent data loss:
//
// 1. The branch meta says Recovered and records the fork digest.
// 2. The transcript still matches that fork digest: the branch was never
// continued on. A single follow-up turn disqualifies it permanently.
// 3. The parent transcript (meta.ParentID, same directory) exists and covers
// the branch content — equal digest, or the branch is a strict prefix
// (allowing a compatible leading-system swap). These are the same checks
// SaveRecoveryBranch uses to declare a recovery not needed in the first
// place, so "covered" here means the fork preserves nothing unique.
// 4. No live runtime holds the branch's session lease.
// 5. The branch has been idle for at least grace.
//
// It returns candidate paths only; disposal (trash, delete) is caller policy.
func ReclaimableRecoveryBranches(dir string, now time.Time, grace time.Duration) ([]string, error) {
dir = strings.TrimSpace(dir)
if dir == "" {
return nil, nil
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") || strings.HasSuffix(e.Name(), ".events.jsonl") {
continue
}
path := filepath.Join(dir, e.Name())
if !IsVisibleSession(path) {
continue
}
meta, ok, err := LoadBranchMeta(path)
if err != nil || !ok || !meta.Recovered || strings.TrimSpace(meta.RecoveryDigest) == "" {
continue
}
if strings.TrimSpace(meta.ParentID) == "" {
continue
}
idleSince := meta.UpdatedAt
if idleSince.IsZero() {
info, err := e.Info()
if err != nil {
continue
}
idleSince = info.ModTime()
}
if now.Sub(idleSince) < grace {
continue
}
if SessionLeaseHeld(path) {
continue
}
if !recoveryBranchCoveredByParent(path, dir, meta) {
continue
}
out = append(out, path)
}
return out, nil
}