feat(daemon): add sync backoff and consolidate hints into tips

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
This commit is contained in:
Ryan Snodgrass
2025-12-26 19:09:31 -05:00
parent 721ae70ccb
commit 252de1cdba
8 changed files with 240 additions and 2 deletions

View File

@@ -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/`

View File

@@ -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)

View File

@@ -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")
}
}

165
cmd/bd/daemon_sync_state.go Normal file
View File

@@ -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)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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")
}
},

View File

@@ -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