esengine--deepseek-reasonix
191 行
6.7 KiB
Go
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
|
|
}
|