fix: break sync feedback loop, respect timestamps, surface errors

- Add migration v2: source column on change_log to distinguish local
  vs sync-originated entries, preventing the echo loop where synced
  tasks get re-pushed as local changes
- PushChanges handler now skips save when server version is newer
- Client PushChanges/pushQueuedChanges collect and report marshal errors
  instead of silently dropping them
- De-duplicate getLocalChanges/getLastSyncTime into exported sync
  package functions
- Fix logConflict winner detection via pointer identity instead of
  fragile UUID+timestamp comparison
- Fix sync down to actually parse, save, and tag-sync pulled changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 01:11:04 +01:00
parent 0e3750e755
commit a11f452d3b
5 changed files with 151 additions and 87 deletions
+52 -61
View File
@@ -224,8 +224,8 @@ var syncUpCmd = &cobra.Command{
client := sync.NewClient(cfg.SyncURL, cfg.SyncAPIKey, cfg.SyncClientID)
// Get local changes
lastSync := getLastSyncTime(cfg.SyncClientID)
localChanges, err := getLocalChanges(lastSync)
lastSync := sync.GetLastSyncTime(cfg.SyncClientID)
localChanges, err := sync.GetLocalChanges(lastSync)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting local changes: %v\n", err)
os.Exit(1)
@@ -264,7 +264,7 @@ var syncDownCmd = &cobra.Command{
}
client := sync.NewClient(cfg.SyncURL, cfg.SyncAPIKey, cfg.SyncClientID)
lastSync := getLastSyncTime(cfg.SyncClientID)
lastSync := sync.GetLastSyncTime(cfg.SyncClientID)
changes, err := client.PullChanges(lastSync)
if err != nil {
@@ -277,7 +277,55 @@ var syncDownCmd = &cobra.Command{
return
}
fmt.Printf("✓ Pulled %d changes from server\n", len(changes))
// Parse changes into tasks
tasks, err := client.ParseChanges(changes)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing changes: %v\n", err)
os.Exit(1)
}
// Apply each task locally
var applied int
for _, task := range tasks {
if err := task.Save(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to save task %s: %v\n", task.UUID, err)
continue
}
// Mark as sync-originated to prevent feedback loop
_ = engine.MarkChangeLogAsSync(task.UUID.String())
// Sync tags
savedTask, err := engine.GetTask(task.UUID)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to reload task %s: %v\n", task.UUID, err)
continue
}
currentTags, _ := savedTask.GetTags()
currentSet := make(map[string]bool)
for _, tag := range currentTags {
currentSet[tag] = true
}
desiredSet := make(map[string]bool)
for _, tag := range task.Tags {
desiredSet[tag] = true
}
for tag := range currentSet {
if !desiredSet[tag] {
savedTask.RemoveTag(tag)
}
}
for tag := range desiredSet {
if !currentSet[tag] {
savedTask.AddTag(tag)
}
}
applied++
}
fmt.Printf("✓ Pulled %d changes, applied %d tasks from server\n", len(changes), applied)
},
}
@@ -414,63 +462,6 @@ func init() {
syncCmd.PersistentFlags().BoolVarP(&quietFlag, "quiet", "q", false, "Suppress progress output")
}
// Helper functions
func getLastSyncTime(clientID string) int64 {
db := engine.GetDB()
if db == nil {
return 0
}
var lastSync int64
err := db.QueryRow("SELECT last_sync FROM sync_state WHERE client_id = ?", clientID).Scan(&lastSync)
if err != nil {
return 0
}
return lastSync
}
func getLocalChanges(since int64) ([]*engine.Task, error) {
db := engine.GetDB()
if db == nil {
return nil, fmt.Errorf("database not initialized")
}
rows, err := db.Query(`
SELECT DISTINCT task_uuid
FROM change_log
WHERE changed_at > ?
ORDER BY changed_at ASC
`, since)
if err != nil {
return nil, err
}
defer rows.Close()
var tasks []*engine.Task
for rows.Next() {
var uuidStr string
if err := rows.Scan(&uuidStr); err != nil {
continue
}
taskUUID, err := uuid.Parse(uuidStr)
if err != nil {
continue
}
task, err := engine.GetTask(taskUUID)
if err != nil {
continue
}
tasks = append(tasks, task)
}
return tasks, nil
}
func formatTimestamp(ts int64) string {
t := time.Unix(ts, 0)
now := time.Now()