项目文件夹

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

230 行
8.7 KiB
Go

package agent
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/provider"
"reasonix/internal/store"
)
// forkRecoveryBranch builds a real conflict-recovery branch: a diverged disk
// parent plus a stale in-memory session, forked through SaveRecoveryBranch —
// the exact artifacts GC will meet in the field.
func forkRecoveryBranch(t *testing.T, dir, name string) (parentPath, branchPath string, branchMsgs []provider.Message) {
t.Helper()
parentPath = filepath.Join(dir, name+".jsonl")
disk := NewSession("sys")
disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk " + name})
if err := disk.Save(parentPath); err != nil {
t.Fatalf("Save parent: %v", err)
}
stale := NewSession("sys")
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local " + name})
info, err := stale.SaveRecoveryBranch(RecoveryBranchOptions{OriginalPath: parentPath})
if err != nil {
t.Fatalf("SaveRecoveryBranch: %v", err)
}
return parentPath, info.Path, stale.Snapshot()
}
// coverBranchInParent rewrites the parent so it contains the branch's content
// plus later turns — the "original session went on and kept everything the
// fork preserved" shape that makes the fork redundant.
func coverBranchInParent(t *testing.T, parentPath string, branchMsgs []provider.Message) {
t.Helper()
merged := NewSession("")
merged.Messages = append([]provider.Message(nil), branchMsgs...)
merged.Add(provider.Message{Role: provider.RoleAssistant, Content: "answered after recovery"})
if err := merged.Save(parentPath); err != nil {
t.Fatalf("Save covering parent: %v", err)
}
}
func TestReclaimableRecoveryBranchesCollectsOnlyCoveredIdleForks(t *testing.T) {
dir := t.TempDir()
later := time.Now().Add(48 * time.Hour)
// Covered + idle + unleased: reclaimable.
_, covered, coveredMsgs := forkRecoveryBranch(t, dir, "covered")
coverBranchInParent(t, filepath.Join(dir, "covered.jsonl"), coveredMsgs)
// Diverged parent: the fork holds turns that exist nowhere else — kept.
forkRecoveryBranch(t, dir, "diverged")
// Continued on: one follow-up turn on the branch disqualifies it forever.
continuedParent, continuedBranch, continuedMsgs := forkRecoveryBranch(t, dir, "continued")
coverBranchInParent(t, continuedParent, continuedMsgs)
cont, err := LoadSession(continuedBranch)
if err != nil {
t.Fatalf("LoadSession continued branch: %v", err)
}
cont.Add(provider.Message{Role: provider.RoleAssistant, Content: "user kept chatting here"})
if err := cont.Save(continuedBranch); err != nil {
t.Fatalf("Save continued branch: %v", err)
}
got, err := ReclaimableRecoveryBranches(dir, later, RecoveryGCGracePeriod)
if err != nil {
t.Fatalf("ReclaimableRecoveryBranches: %v", err)
}
if len(got) != 1 || got[0] != covered {
t.Fatalf("reclaimable = %v, want only %q", got, covered)
}
}
func TestReclaimableRecoveryBranchesRespectsGraceLeaseAndMissingParent(t *testing.T) {
dir := t.TempDir()
later := time.Now().Add(48 * time.Hour)
parentPath, branchPath, branchMsgs := forkRecoveryBranch(t, dir, "guarded")
coverBranchInParent(t, parentPath, branchMsgs)
// Fresh fork inside the grace window: kept.
if got, err := ReclaimableRecoveryBranches(dir, time.Now(), RecoveryGCGracePeriod); err != nil || len(got) != 0 {
t.Fatalf("within grace = %v err=%v, want none", got, err)
}
// Lease held (by this very process): kept.
lease, err := TryAcquireSessionLease(branchPath)
if err != nil {
t.Fatalf("TryAcquireSessionLease: %v", err)
}
if !SessionLeaseHeld(branchPath) {
t.Fatal("SessionLeaseHeld = false while this process holds the lease")
}
if got, err := ReclaimableRecoveryBranches(dir, later, RecoveryGCGracePeriod); err != nil || len(got) != 0 {
t.Fatalf("with lease held = %v err=%v, want none", got, err)
}
lease.Release()
if SessionLeaseHeld(branchPath) {
t.Fatal("SessionLeaseHeld = true after release")
}
// Released + idle: reclaimable now.
if got, err := ReclaimableRecoveryBranches(dir, later, RecoveryGCGracePeriod); err != nil || len(got) != 1 || got[0] != branchPath {
t.Fatalf("after release = %v err=%v, want %q", got, err, branchPath)
}
// Parent gone: content is no longer covered anywhere — kept.
for _, suffix := range []string{"", ".events.jsonl", ".meta"} {
if err := os.Remove(parentPath + suffix); err != nil && !os.IsNotExist(err) {
t.Fatalf("remove parent artifact %s: %v", suffix, err)
}
}
if got, err := ReclaimableRecoveryBranches(dir, later, RecoveryGCGracePeriod); err != nil || len(got) != 0 {
t.Fatalf("parent missing = %v err=%v, want none", got, err)
}
}
func TestRecoveryBranchCoveredByParentReadsActualContent(t *testing.T) {
dir := t.TempDir()
_, divergedBranch, _ := forkRecoveryBranch(t, dir, "diverged-proof")
if RecoveryBranchCoveredByParent(divergedBranch, dir) {
t.Fatal("diverged parent reported as covering its recovery branch")
}
coveredParent, coveredBranch, coveredMsgs := forkRecoveryBranch(t, dir, "covered-proof")
coverBranchInParent(t, coveredParent, coveredMsgs)
if !RecoveryBranchCoveredByParent(coveredBranch, dir) {
t.Fatal("parent containing the recovery transcript did not cover the branch")
}
// Restore the fork metadata after continuing the transcript. This models a
// stale sidecar that still claims content_digest == recovery_digest even
// though the actual branch has changed.
staleMeta, ok, err := LoadBranchMeta(coveredBranch)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta stale branch: ok=%v err=%v", ok, err)
}
continued, err := LoadSession(coveredBranch)
if err != nil {
t.Fatalf("LoadSession stale branch: %v", err)
}
continued.Add(provider.Message{Role: provider.RoleAssistant, Content: "continued after sidecar snapshot"})
if err := continued.Save(coveredBranch); err != nil {
t.Fatalf("Save continued branch: %v", err)
}
if err := SaveBranchMetaPreserveUpdated(coveredBranch, staleMeta); err != nil {
t.Fatalf("restore stale branch meta: %v", err)
}
if RecoveryBranchCoveredByParent(coveredBranch, dir) {
t.Fatal("stale sidecar authorized cleanup of changed branch content")
}
missingParent, missingBranch, missingMsgs := forkRecoveryBranch(t, dir, "missing-proof")
coverBranchInParent(t, missingParent, missingMsgs)
if !RecoveryBranchCoveredByParent(missingBranch, dir) {
t.Fatal("missing-parent fixture was not covered before parent removal")
}
for _, suffix := range []string{"", ".events.jsonl", ".meta"} {
if err := os.Remove(missingParent + suffix); err != nil && !os.IsNotExist(err) {
t.Fatalf("remove parent artifact %s: %v", suffix, err)
}
}
if RecoveryBranchCoveredByParent(missingBranch, dir) {
t.Fatal("missing parent reported as covering its recovery branch")
}
}
func TestRecoveryParentGuardBlocksRewindAfterValidation(t *testing.T) {
dir := t.TempDir()
parentPath, branchPath, branchMsgs := forkRecoveryBranch(t, dir, "rewind-race")
coverBranchInParent(t, parentPath, branchMsgs)
guard, err := TryAcquireRecoveryParentGuard(branchPath, dir)
if err != nil {
t.Fatalf("TryAcquireRecoveryParentGuard: %v", err)
}
// Force the dangerous ordering: coverage validation has completed, then a
// concurrent rewind tries to take the parent's save lock before purge. The
// guard must keep that lock unavailable until the caller finishes deleting
// the redundant branch.
if lock, err := tryTakeSessionLockFile(store.SessionLockFile(parentPath)); !errors.Is(err, errSessionFileLockHeld) {
if lock != nil {
lock.Unlock()
}
guard.Release()
t.Fatalf("parent save lock after coverage validation = %v, want errSessionFileLockHeld", err)
}
guard.Release()
parent, err := LoadSession(parentPath)
if err != nil {
t.Fatalf("LoadSession parent: %v", err)
}
parent.Messages = append([]provider.Message(nil), parent.Messages[:1]...)
if err := parent.SaveRewrite(parentPath); err != nil {
t.Fatalf("SaveRewrite after guard release: %v", err)
}
if RecoveryBranchCoveredByParent(branchPath, dir) {
t.Fatal("rewound parent still reported as covering the recovery branch")
}
}
func TestRecoveryParentGuardRefusesInFlightParentRewrite(t *testing.T) {
dir := t.TempDir()
parentPath, branchPath, branchMsgs := forkRecoveryBranch(t, dir, "rewrite-busy")
coverBranchInParent(t, parentPath, branchMsgs)
unlock, err := lockSessionFile(parentPath)
if err != nil {
t.Fatalf("lockSessionFile: %v", err)
}
defer unlock()
if guard, err := TryAcquireRecoveryParentGuard(branchPath, dir); !errors.Is(err, ErrSessionLeaseHeld) {
if guard != nil {
guard.Release()
}
t.Fatalf("guard during parent rewrite err = %v, want ErrSessionLeaseHeld", err)
}
}