diff --git a/cmd/options.go b/cmd/options.go index 8326de88..9e686207 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -432,6 +432,10 @@ func configureSessionHeavy(sess *engine.Session) { // Memory initialization touches persisted state and optional bridges. enhancedMem := memory.NewEnhancedMemoryManager(cwd) if enhancedMem.Yaad.Ready() { + // Periodic yaad snapshots only for long-lived sessions — short-lived + // diagnostic bridges skip scheduling entirely. + enhancedMem.Yaad.EnsureBackups() + sess.MemorySvc().SetMemory(enhancedMem) sess.MemorySvc().SetYaad(enhancedMem.Yaad) sess.MemorySvc().SetEnhanced(enhancedMem) diff --git a/external/yaad b/external/yaad index 8bc7a83d..fbc04665 160000 --- a/external/yaad +++ b/external/yaad @@ -1 +1 @@ -Subproject commit 8bc7a83d4a72e021b3d3a854546340a2bb68b7d7 +Subproject commit fbc04665cca69a56defee566a80404ed399127ba diff --git a/go.mod b/go.mod index 7fedc4a7..6a86554f 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/GrayCodeAI/inspect v0.0.0-20260726091806-08f3151d5738 github.com/GrayCodeAI/sight v0.0.0-20260726091804-84c96edfc589 github.com/GrayCodeAI/tok v0.1.5-0.20260812042126-622cafb3bd1b - github.com/GrayCodeAI/yaad v0.2.1-0.20260815092111-8bc7a83d4a72 + github.com/GrayCodeAI/yaad v0.2.1-0.20260815132449-fbc04665cca6 github.com/alecthomas/chroma/v2 v2.26.1 github.com/bwmarrin/discordgo v0.28.1 github.com/charmbracelet/x/ansi v0.11.7 diff --git a/go.sum b/go.sum index 075e1a9c..d8771348 100644 --- a/go.sum +++ b/go.sum @@ -30,8 +30,8 @@ github.com/GrayCodeAI/tok v0.1.5-0.20260812042126-622cafb3bd1b h1:iVEaIbxkbyQ751 github.com/GrayCodeAI/tok v0.1.5-0.20260812042126-622cafb3bd1b/go.mod h1:/KTHlWg+qg8fDV8qRsLUfp4VKtt5seTyED1byxldBtE= github.com/GrayCodeAI/trace v0.1.4-0.20260812042155-a31e98c7d75b h1:xqdfzKQ+wzy7B0aTr14Y7CXply41M7z2LoG/qjO0cYE= github.com/GrayCodeAI/trace v0.1.4-0.20260812042155-a31e98c7d75b/go.mod h1:RPt/KV4f2DKezxzqAPe960z3vVmO5eJUGB/ZDlV/sOI= -github.com/GrayCodeAI/yaad v0.2.1-0.20260815092111-8bc7a83d4a72 h1:WPHUhoPW3jay4VbYgRxLHOAFXKKFmgm08FtKFhRPRns= -github.com/GrayCodeAI/yaad v0.2.1-0.20260815092111-8bc7a83d4a72/go.mod h1:oL8Wr0JPEtayrKrQxVqVUQODDQ8g8YjzUadETQVE55A= +github.com/GrayCodeAI/yaad v0.2.1-0.20260815132449-fbc04665cca6 h1:Udda7o9RremeNJ4ETwb6Ltu546PsTSS/KoXP/R8VOOU= +github.com/GrayCodeAI/yaad v0.2.1-0.20260815132449-fbc04665cca6/go.mod h1:oL8Wr0JPEtayrKrQxVqVUQODDQ8g8YjzUadETQVE55A= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= diff --git a/internal/intelligence/memory/yaad_bridge.go b/internal/intelligence/memory/yaad_bridge.go index 93921894..f608df04 100644 --- a/internal/intelligence/memory/yaad_bridge.go +++ b/internal/intelligence/memory/yaad_bridge.go @@ -23,6 +23,27 @@ import ( "github.com/GrayCodeAI/yaad/storage" ) +// Backup tuning for the yaad snapshot scheduler. Snapshots go to +// ~/.yaad/data/backups/, kept hourly with a bounded window so an idle or +// crash-prone host always has a recent consistent copy of the memory DB. +const ( + yaadBackupDir = "backups" + yaadBackupInterval = time.Hour + yaadBackupKeep = 7 + yaadBackupMaxAge = 30 * 24 * time.Hour +) + +// yaadBackupsMu guards yaadBackupDirs. yaadBackupDirs tracks which backup +// directories already have a running scheduler so the many bridges a host +// creates (one per callsite) share a single loop per directory instead of +// stacking duplicate schedulers. Keying on the directory (not a global +// sync.Once) keeps tests with distinct temp homes isolated and still dedups +// the repeated NewYaadBridge calls a real host makes against one home. +var ( + yaadBackupsMu sync.Mutex + yaadBackupDirs = make(map[string]struct{}) +) + // YaadBridge connects hawk's memory system to the yaad memory graph. // If yaad is not initialized (missing DB), operations return a BridgeError // and log a warning on first access. @@ -33,6 +54,9 @@ type YaadBridge struct { ready bool warnOnce sync.Once + dbDir string + backupSched *storage.BackupScheduler + graphSessionID string graphScope graphcontracts.Scope } @@ -66,9 +90,52 @@ func (b *YaadBridge) init() { b.store = store b.engine = eng + b.dbDir = dbDir b.ready = true } +// EnsureBackups starts the yaad snapshot scheduler for the bridge's database +// directory. It is called once by the long-lived memory manager at session +// startup — not from NewYaadBridge — so short-lived bridges (diagnostics, +// status queries, tests) never leave scheduler goroutines writing into +// their working directories. The directory is claimed so concurrent or +// repeated calls reuse one loop; Close releases the claim and stops the +// loop this bridge started. +func (b *YaadBridge) EnsureBackups() { + if b == nil || !b.ready { + return + } + + yaadBackupsMu.Lock() + if _, busy := yaadBackupDirs[b.dbDir]; busy { + yaadBackupsMu.Unlock() + return + } + // Claim the directory under the lock so concurrent callers cannot both + // start a scheduler; released on failure so a later caller can retry. + yaadBackupDirs[b.dbDir] = struct{}{} + yaadBackupsMu.Unlock() + + sched, err := b.store.ScheduleBackups( + filepath.Join(b.dbDir, yaadBackupDir), + yaadBackupInterval, + yaadBackupKeep, + yaadBackupMaxAge, + ) + if err != nil { + yaadBackupsMu.Lock() + delete(yaadBackupDirs, b.dbDir) + yaadBackupsMu.Unlock() + slog.Warn("[hawk/memory] yaad backup scheduler not started", "error", err) + return + } + sched.Start() + + b.mu.Lock() + b.backupSched = sched + b.mu.Unlock() +} + // Ready reports whether the yaad bridge is initialized and usable. func (b *YaadBridge) Ready() bool { return b.ready @@ -673,15 +740,30 @@ func (b *YaadBridge) GetFullContent(ids []string) ([]FullResult, error) { } // Close shuts down the yaad engine and closes the database connection. +// If this bridge started the backup scheduler, it is stopped here and the +// directory claim released so a later bridge can restart snapshots. func (b *YaadBridge) Close() { - if !b.ready { + b.mu.Lock() + sched := b.backupSched + b.backupSched = nil + wasReady := b.ready + b.ready = false + b.mu.Unlock() + + if sched != nil { + sched.Stop() + yaadBackupsMu.Lock() + delete(yaadBackupDirs, b.dbDir) + yaadBackupsMu.Unlock() + } + + if !wasReady { return } b.engine.Close() if b.store != nil { _ = b.store.Close() } - b.ready = false } func bridgeDigest(value string) string { diff --git a/internal/intelligence/memory/yaad_bridge_integration_test.go b/internal/intelligence/memory/yaad_bridge_integration_test.go index 5ae9613a..3cae7f93 100644 --- a/internal/intelligence/memory/yaad_bridge_integration_test.go +++ b/internal/intelligence/memory/yaad_bridge_integration_test.go @@ -172,6 +172,68 @@ func TestYaadBridge_Close(t *testing.T) { _ = b.store.Close() } +// TestYaadBridge_EnsureBackups verifies the scheduler is started once per +// database directory, idempotent across repeated calls, and torn down by +// Close so a later bridge can restart snapshots. +func TestYaadBridge_EnsureBackups(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + _ = os.MkdirAll(dir+"/.yaad/data", 0o755) + + b := NewYaadBridge() + if !b.ready { + t.Skip("yaad not available") + } + + b.EnsureBackups() + b.EnsureBackups() // must be a no-op, not a second scheduler + + b.mu.Lock() + sched := b.backupSched + b.mu.Unlock() + if sched == nil { + t.Fatal("expected backupSched to be set after EnsureBackups") + } + + // Second bridge on the same dbDir reuses the existing claim. + b2 := NewYaadBridge() + if !b2.ready { + t.Skip("yaad not available") + } + b2.EnsureBackups() + b2.mu.Lock() + dup := b2.backupSched + b2.mu.Unlock() + if dup != nil { + t.Fatal("second bridge must reuse the existing scheduler") + } + // Close a non-owning bridge: no scheduler to stop, no claim freed. + b2.Close() + + b.Close() + b.mu.Lock() + stopped := b.backupSched == nil + b.mu.Unlock() + if !stopped { + t.Fatal("expected Close to clear the scheduler reference") + } + + // After Close the directory claim is released: a fresh bridge can + // re-register without error. + b3 := NewYaadBridge() + if !b3.ready { + t.Skip("yaad not available") + } + b3.EnsureBackups() + b3.mu.Lock() + restarted := b3.backupSched != nil + b3.mu.Unlock() + if !restarted { + t.Fatal("expected a fresh bridge to start its own scheduler after Close") + } + b3.Close() +} + func TestConfidenceTracker_WithBridge(t *testing.T) { b := newTestBridge(t) if !b.ready {