fix: resolve duplicate-file failures, alreadySafe reset, and backup restart loop

BackupEngine: after all retries fail, call fileExists() before recording a
failure. If the file is on NAS (uploaded earlier but fileExists silently
threw on the pre-check), count it as skipped and add it to manifestEntries —
this ensures writeManifest includes every safe asset and prevents alreadySafe
from dropping to 1 after the post-backup reconcile.

BackupView: remove the immediate refreshAndWait from onChange(.failed). That
call raced with refreshAfterBackup's writeManifest, cancelled the manifest-
correct refresh, then reconciled against a stale 1-entry NAS manifest.
Auto-resolve (.failed → .completed when needBackup == 0) is now owned by the
coordinator so it runs after writeManifest finishes.

AutoBackupCoordinator: add backupTriggerArmed flag (set by onActive/
handleNASReachable, cleared by handleReconciliationComplete). Post-backup
reconciles from refreshAfterBackup are no longer able to re-trigger auto-
backup — only an explicit app-open or LAN-join can arm the trigger. Also
added auto-resolve: calls engine.resolveSuccess() when job is .failed but
needBackup == 0, which fires the .completed green-flash path in BackupView.

Co-Authored-By: Kutesir <kutesir@provoc.ug>
Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
Robin Kutesa
2026-05-18 18:55:27 +03:00
parent 0acf111d32
commit 8d8b6c4418
3 changed files with 70 additions and 25 deletions

View File

@@ -153,18 +153,11 @@ struct BackupView: View {
try? await Task.sleep(nanoseconds: 500_000_000)
statusService.refresh(force: true)
}
} else if case .failed = status {
// Reconcile if all files landed on NAS despite the failure, auto-resolve to .completed
Task {
await statusService.refreshAndWait(force: true)
if statusService.snapshot.needBackup == 0 {
engine.resolveSuccess()
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { ctaFlashGreen = true }
try? await Task.sleep(nanoseconds: 2_500_000_000)
withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { ctaFlashGreen = false }
}
}
}
// .failed auto-resolve is owned by AutoBackupCoordinator.handleReconciliationComplete.
// It waits for refreshAfterBackup's writeManifest to finish before reconciling, then
// calls engine.resolveSuccess() when needBackup == 0, which re-triggers .completed above.
// Calling refreshAndWait here would race with writeManifest and read a stale NAS manifest.
}
}