From 252de1cdba6d9b8be70a9c11fbb8029d30e0c2d1 Mon Sep 17 00:00:00 2001 From: Ryan Snodgrass Date: Fri, 26 Dec 2025 19:09:31 -0500 Subject: [PATCH] feat(daemon): add sync backoff and consolidate hints into tips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daemon sync improvements: - Adds exponential backoff on sync failures (30s → 1m → 2m → 5m → 10m → 30m cap) - Tracks sync state in .beads/sync-state.json (NeedsManualSync, FailureCount, BackoffUntil) - Resets backoff on daemon start and manual bd sync - Adds sync-state.json to default .gitignore Tips consolidation (following ox-cli pattern): - Moves sync conflict hint from hints.go into tips.go - Proactive health checks trump educational tips - Uses InjectTip() with high priority (200) and 100% probability for urgent warnings - Removes deprecated hints.go --- AGENT_INSTRUCTIONS.md | 14 +++ cmd/bd/daemon.go | 5 ++ cmd/bd/daemon_sync.go | 21 ++++- cmd/bd/daemon_sync_state.go | 165 ++++++++++++++++++++++++++++++++++++ cmd/bd/doctor/gitignore.go | 1 + cmd/bd/main.go | 3 + cmd/bd/sync.go | 8 ++ cmd/bd/tips.go | 25 ++++++ 8 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 cmd/bd/daemon_sync_state.go diff --git a/AGENT_INSTRUCTIONS.md b/AGENT_INSTRUCTIONS.md index 58e339c1..7be1057b 100644 --- a/AGENT_INSTRUCTIONS.md +++ b/AGENT_INSTRUCTIONS.md @@ -244,6 +244,20 @@ Without the pre-push hook, you can have database changes committed locally but s ## Common Development Tasks +### CLI Design Principles + +**Minimize cognitive overload.** Every new command, flag, or option adds cognitive burden for users. Before adding anything: + +1. **Recovery/fix operations → `bd doctor --fix`**: Don't create separate commands like `bd recover` or `bd repair`. Doctor already detects problems - let `--fix` handle remediation. This keeps all health-related operations in one discoverable place. + +2. **Prefer flags on existing commands**: Before creating a new command, ask: "Can this be a flag on an existing command?" Example: `bd list --stale` instead of `bd stale`. + +3. **Consolidate related operations**: Related operations should live together. Daemon management uses `bd daemons {list,health,killall}`, not separate top-level commands. + +4. **Count the commands**: Run `bd --help` and count. If we're approaching 30+ commands, we have a discoverability problem. Consider subcommand grouping. + +5. **New commands need strong justification**: A new command should represent a fundamentally different operation, not just a convenience wrapper. + ### Adding a New Command 1. Create file in `cmd/bd/` diff --git a/cmd/bd/daemon.go b/cmd/bd/daemon.go index 0cab9bdd..ba4ebee6 100644 --- a/cmd/bd/daemon.go +++ b/cmd/bd/daemon.go @@ -355,6 +355,11 @@ func runDaemonLoop(interval time.Duration, autoCommit, autoPush, autoPull, local // Check for multiple .db files (ambiguity error) beadsDir := filepath.Dir(daemonDBPath) + + // Reset backoff on daemon start (fresh start, but preserve NeedsManualSync hint) + if !localMode { + ResetBackoffOnDaemonStart(beadsDir) + } matches, err := filepath.Glob(filepath.Join(beadsDir, "*.db")) if err == nil && len(matches) > 1 { // Filter out backup files (*.backup-*.db, *.backup.db) diff --git a/cmd/bd/daemon_sync.go b/cmd/bd/daemon_sync.go index aac41f5b..b5a277d8 100644 --- a/cmd/bd/daemon_sync.go +++ b/cmd/bd/daemon_sync.go @@ -529,6 +529,19 @@ func performAutoImport(ctx context.Context, store storage.Storage, skipGit bool, if skipGit { mode = "local auto-import" } + + // Check backoff before attempting sync (skip for local mode) + if !skipGit { + jsonlPath := findJSONLPath() + if jsonlPath != "" { + beadsDir := filepath.Dir(jsonlPath) + if ShouldSkipSync(beadsDir) { + log.log("Skipping %s: in backoff period", mode) + return + } + } + } + log.log("Starting %s...", mode) jsonlPath := findJSONLPath() @@ -579,14 +592,16 @@ func performAutoImport(ctx context.Context, store storage.Storage, skipGit bool, // Try sync branch first pulled, err := syncBranchPull(importCtx, store, log) if err != nil { - log.log("Sync branch pull failed: %v", err) + backoff := RecordSyncFailure(beadsDir, err.Error()) + log.log("Sync branch pull failed: %v (backoff: %v)", err, backoff) return } // If sync branch not configured, use regular pull if !pulled { if err := gitPull(importCtx); err != nil { - log.log("Pull failed: %v", err) + backoff := RecordSyncFailure(beadsDir, err.Error()) + log.log("Pull failed: %v (backoff: %v)", err, backoff) return } log.log("Pulled from remote") @@ -622,6 +637,8 @@ func performAutoImport(ctx context.Context, store storage.Storage, skipGit bool, if skipGit { log.log("Local auto-import complete") } else { + // Record success to clear backoff state + RecordSyncSuccess(beadsDir) log.log("Auto-import complete") } } diff --git a/cmd/bd/daemon_sync_state.go b/cmd/bd/daemon_sync_state.go new file mode 100644 index 00000000..0e292ce0 --- /dev/null +++ b/cmd/bd/daemon_sync_state.go @@ -0,0 +1,165 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "time" +) + +// SyncState tracks daemon sync health for backoff and user hints. +// Stored in .beads/sync-state.json (gitignored, local-only). +type SyncState struct { + LastFailure time.Time `json:"last_failure,omitempty"` + FailureCount int `json:"failure_count"` + BackoffUntil time.Time `json:"backoff_until,omitempty"` + NeedsManualSync bool `json:"needs_manual_sync"` + FailureReason string `json:"failure_reason,omitempty"` +} + +const ( + syncStateFile = "sync-state.json" + // Backoff schedule: 30s, 1m, 2m, 5m, 10m, 30m (cap) + maxBackoffDuration = 30 * time.Minute + // Clear stale state after 24 hours + staleStateThreshold = 24 * time.Hour +) + +var ( + // backoffSchedule defines the exponential backoff durations + backoffSchedule = []time.Duration{ + 30 * time.Second, + 1 * time.Minute, + 2 * time.Minute, + 5 * time.Minute, + 10 * time.Minute, + 30 * time.Minute, + } + // syncStateMu protects concurrent access to sync state file + syncStateMu sync.Mutex +) + +// LoadSyncState loads the sync state from .beads/sync-state.json. +// Returns empty state if file doesn't exist or is stale. +func LoadSyncState(beadsDir string) SyncState { + syncStateMu.Lock() + defer syncStateMu.Unlock() + + statePath := filepath.Join(beadsDir, syncStateFile) + data, err := os.ReadFile(statePath) // #nosec G304 - path constructed from beadsDir + if err != nil { + return SyncState{} + } + + var state SyncState + if err := json.Unmarshal(data, &state); err != nil { + return SyncState{} + } + + // Clear stale state (older than 24h with no recent failures) + if !state.LastFailure.IsZero() && time.Since(state.LastFailure) > staleStateThreshold { + _ = os.Remove(statePath) + return SyncState{} + } + + return state +} + +// SaveSyncState saves the sync state to .beads/sync-state.json. +func SaveSyncState(beadsDir string, state SyncState) error { + syncStateMu.Lock() + defer syncStateMu.Unlock() + + statePath := filepath.Join(beadsDir, syncStateFile) + + // If state is empty/reset, remove the file + if state.FailureCount == 0 && !state.NeedsManualSync { + _ = os.Remove(statePath) + return nil + } + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + + return os.WriteFile(statePath, data, 0600) +} + +// ClearSyncState removes the sync state file. +func ClearSyncState(beadsDir string) error { + syncStateMu.Lock() + defer syncStateMu.Unlock() + + statePath := filepath.Join(beadsDir, syncStateFile) + err := os.Remove(statePath) + if os.IsNotExist(err) { + return nil + } + return err +} + +// RecordSyncFailure updates the sync state after a failure. +// Returns the duration until next retry. +func RecordSyncFailure(beadsDir string, reason string) time.Duration { + state := LoadSyncState(beadsDir) + + state.LastFailure = time.Now() + state.FailureCount++ + state.FailureReason = reason + + // Calculate backoff duration + backoffIndex := state.FailureCount - 1 + if backoffIndex >= len(backoffSchedule) { + backoffIndex = len(backoffSchedule) - 1 + } + backoff := backoffSchedule[backoffIndex] + + state.BackoffUntil = time.Now().Add(backoff) + + // Mark as needing manual sync after 3 failures (likely a conflict) + if state.FailureCount >= 3 { + state.NeedsManualSync = true + } + + _ = SaveSyncState(beadsDir, state) + return backoff +} + +// RecordSyncSuccess clears the sync state after a successful sync. +func RecordSyncSuccess(beadsDir string) { + _ = ClearSyncState(beadsDir) +} + +// ShouldSkipSync returns true if we're still in the backoff period. +func ShouldSkipSync(beadsDir string) bool { + state := LoadSyncState(beadsDir) + if state.BackoffUntil.IsZero() { + return false + } + return time.Now().Before(state.BackoffUntil) +} + +// ResetBackoffOnDaemonStart resets backoff counters when daemon starts, +// but preserves NeedsManualSync flag so hints still show. +// This allows a fresh start while keeping user informed of conflicts. +func ResetBackoffOnDaemonStart(beadsDir string) { + state := LoadSyncState(beadsDir) + + // Nothing to reset + if state.FailureCount == 0 && !state.NeedsManualSync { + return + } + + // Reset backoff but preserve NeedsManualSync + needsManual := state.NeedsManualSync + reason := state.FailureReason + + state = SyncState{ + NeedsManualSync: needsManual, + FailureReason: reason, + } + + _ = SaveSyncState(beadsDir, state) +} diff --git a/cmd/bd/doctor/gitignore.go b/cmd/bd/doctor/gitignore.go index e64cec14..8b141e9d 100644 --- a/cmd/bd/doctor/gitignore.go +++ b/cmd/bd/doctor/gitignore.go @@ -19,6 +19,7 @@ daemon.lock daemon.log daemon.pid bd.sock +sync-state.json # Local version tracking (prevents upgrade notification spam after git ops) .local_version diff --git a/cmd/bd/main.go b/cmd/bd/main.go index 12f27cf5..0b5b52be 100644 --- a/cmd/bd/main.go +++ b/cmd/bd/main.go @@ -864,6 +864,9 @@ var rootCmd = &cobra.Command{ debug.Logf("loaded %d molecules from %v", result.Loaded, result.Sources) } } + + // Tips (including sync conflict proactive checks) are shown via maybeShowTip() + // after successful command execution, not in PreRun }, PersistentPostRun: func(cmd *cobra.Command, args []string) { // Handle --no-db mode: write memory storage back to JSONL diff --git a/cmd/bd/sync.go b/cmd/bd/sync.go index 2ea2912a..21b10d47 100644 --- a/cmd/bd/sync.go +++ b/cmd/bd/sync.go @@ -361,6 +361,9 @@ Use --merge to merge the sync branch back to main branch.`, } } + // Clear sync state on successful sync (daemon backoff/hints) + _ = ClearSyncState(beadsDir) + fmt.Println("\n✓ Sync complete") return } @@ -711,6 +714,11 @@ Use --merge to merge the sync branch back to main branch.`, skipFinalFlush = true } + // Clear sync state on successful sync (daemon backoff/hints) + if bd := beads.FindBeadsDir(); bd != "" { + _ = ClearSyncState(bd) + } + fmt.Println("\n✓ Sync complete") } }, diff --git a/cmd/bd/tips.go b/cmd/bd/tips.go index 0c486fd0..e0cac50b 100644 --- a/cmd/bd/tips.go +++ b/cmd/bd/tips.go @@ -14,6 +14,7 @@ import ( "sync" "time" + "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/storage" ) @@ -353,6 +354,30 @@ func initDefaultTips() { return isClaudeDetected() && !isClaudeSetupComplete() }, ) + + // Sync conflict tip - ALWAYS show when sync has failed and needs manual intervention + // This is a proactive health check that trumps educational tips (ox-cli pattern) + InjectTip( + "sync_conflict", + "Run 'bd sync' to resolve sync conflict", + 200, // Higher than Claude setup - sync issues are urgent + 0, // No frequency limit - always show when applicable + 1.0, // 100% probability - always show when condition is true + syncConflictCondition, + ) +} + +// syncConflictCondition checks if there's a sync conflict that needs manual resolution. +// This is the condition function for the sync_conflict tip. +func syncConflictCondition() bool { + // Find beads directory to check sync state + beadsDir := beads.FindBeadsDir() + if beadsDir == "" { + return false + } + + state := LoadSyncState(beadsDir) + return state.NeedsManualSync } // init initializes the tip system with default tips