v0.147.3 — 4c follow-up 3: the run does not end with the last app

Third real run, third thing only a live run could show. The per-app legs
finished in ~15 seconds; the remaining 40 of the 57-second run was the shares leg
and forget --prune, during which the card sat frozen on "calibre-web — 8 / 8
fájl". The same frozen-looking silence 4c exists to remove, relocated to the end.

Progress now carries a phase. The post-app stages announce themselves and the
app-scoped counters are cleared when a phase starts, so the last app's finished
numbers are never shown against work that is no longer about that app. Starting
the next app clears the phase again. Pinned by a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
This commit is contained in:
2026-07-19 09:54:18 +02:00
parent 111369dd10
commit 63a22e5911
5 changed files with 88 additions and 2 deletions
+8 -2
View File
@@ -55,7 +55,9 @@ func (m *Manager) SetOffboxNotify(fn func(dur time.Duration, snapshots int, err
}
// SetOffboxOrphanEvent wires the offsite-repo continuity event push (main.go → notifier).
func (m *Manager) SetOffboxOrphanEvent(fn func(eventType, renamedTo string)) { m.offboxOrphanEvent = fn }
func (m *Manager) SetOffboxOrphanEvent(fn func(eventType, renamedTo string)) {
m.offboxOrphanEvent = fn
}
// SetOffboxSSH overrides the raw-ssh exec used for the orphaned-repo move-aside (tests).
func (m *Manager) SetOffboxSSH(fn func(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error)) {
@@ -217,7 +219,9 @@ func (m *Manager) SetOffboxSizer(fn func(path string) int64) { m.offboxSizer = f
func (m *Manager) SetOffboxEnlargeBlockedNotifier(fn func(stack string, estBytes int64, usedGB, quotaGB int)) {
m.offboxEnlargeBlockedNotify = fn
}
func (m *Manager) SetOffboxPlaceCopier(fn func(src, dst string) (int, error)) { m.offboxPlaceCopier = fn }
func (m *Manager) SetOffboxPlaceCopier(fn func(src, dst string) (int, error)) {
m.offboxPlaceCopier = fn
}
// offboxSize returns the mandatory-set byte estimator (nil seam → the real du -sb dirSizeBytes).
func (m *Manager) offboxSize() func(string) int64 {
@@ -923,6 +927,7 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin
// R-7b: the SHARES leg runs AFTER the per-app loop and BEFORE retention, so `forget --group-by
// host,tags` covers the `_shares` group for free. It is placed BEFORE the firstErr return on
// purpose: share protection must not be dropped because some unrelated app failed to push.
m.offboxProgress.setPhase(OffboxPhaseShares)
sharesRes, sharesErr := m.runOffboxSharesLeg(ctx, base, env, t)
m.recordSharesOffsiteStatus(sharesRes)
res.warns = append(res.warns, sharesRes.warns...)
@@ -942,6 +947,7 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin
// unit-only-shape snapshots share a group with its NEW enlarged shape (same <stack> tag) and age
// out naturally — the default host,paths grouping would strand old-shape snapshots in their own
// permanently-retained group. prune takes an EXCLUSIVE lock (the C2 stale-lock step) → resticStep.
m.offboxProgress.setPhase(OffboxPhaseRetention)
fctx, cancel := context.WithTimeout(ctx, offboxBackupTimeout)
defer cancel()
if out, ferr := m.resticStep(fctx, env, base, "prune", "forget", "--group-by", "host,tags", "--keep-daily", "7", "--keep-weekly", "4", "--keep-monthly", "6", "--prune"); ferr != nil {
@@ -52,6 +52,11 @@ type OffboxProgress struct {
// long it has been going — "working on X, 42s" is a completely different message from "0%".
CurrentFile string `json:"current_file"`
ElapsedSec int64 `json:"elapsed_sec"`
// Phase names the part of the run in progress. A run is NOT just the per-app loop: after the last
// app come the shares leg and `forget --prune`, which on the demo box took 40 of a 57-second run.
// Without this the card froze on the last app's finished counters for that whole tail — the same
// silence the slice exists to remove, just relocated. "" = per-app backup.
Phase string `json:"phase"`
}
// resticStatusLine is the subset of restic's `--json` status object we consume. restic emits several
@@ -150,6 +155,7 @@ func (p *offboxProgressState) setApp(app string) {
// A new app resets the byte counters: restic's percentages are per-invocation, and carrying
// the previous app's 100% into the next app's start would show a bar that jumps backwards.
p.cur.CurrentApp = app
p.cur.Phase = ""
p.cur.Percent, p.cur.BytesDone, p.cur.TotalBytes = 0, 0, 0
p.cur.FilesDone, p.cur.TotalFiles = 0, 0
p.cur.CurrentFile, p.cur.ElapsedSec = "", 0
@@ -158,6 +164,27 @@ func (p *offboxProgressState) setApp(app string) {
p.mu.Unlock()
}
// setPhase marks a non-per-app stage of the run and clears the app-scoped counters, so the card
// stops showing the last app's finished numbers against work that is no longer about that app.
func (p *offboxProgressState) setPhase(phase string) {
p.mu.Lock()
if p.live {
p.cur.Phase = phase
p.cur.CurrentApp = ""
p.cur.Percent, p.cur.BytesDone, p.cur.TotalBytes = 0, 0, 0
p.cur.FilesDone, p.cur.TotalFiles = 0, 0
p.cur.CurrentFile, p.cur.ElapsedSec = "", 0
p.cur.DoneHuman, p.cur.TotalHuman = "", ""
}
p.mu.Unlock()
}
// OffboxPhaseShares / OffboxPhaseRetention are the post-app-loop stages.
const (
OffboxPhaseShares = "shares"
OffboxPhaseRetention = "retention"
)
func (p *offboxProgressState) update(r resticProgress) {
p.mu.Lock()
if p.live {
@@ -274,6 +274,32 @@ func TestProgressKeepsLastKnownCurrentFile(t *testing.T) {
}
}
// TestSetPhaseClearsAppScopedCounters — a run is not only the per-app loop. The shares leg and
// forget --prune follow it and took 40 of a 57-second run on the demo box. Without a phase the card
// kept showing the last app's finished counters ("calibre-web 8 / 8") for that whole tail, which is
// the same frozen-looking silence 4c exists to remove, just relocated to the end of the run.
func TestSetPhaseClearsAppScopedCounters(t *testing.T) {
var st offboxProgressState
st.begin()
st.setApp("calibre-web")
st.update(resticProgress{Percent: 100, BytesDone: 850000, TotalBytes: 850000, FilesDone: 8, TotalFiles: 8, CurrentFile: "/data/x"})
st.setPhase(OffboxPhaseRetention)
got := st.snapshot()
if got.Phase != OffboxPhaseRetention {
t.Errorf("phase = %q, want %q", got.Phase, OffboxPhaseRetention)
}
if got.CurrentApp != "" || got.FilesDone != 0 || got.TotalFiles != 0 || got.BytesDone != 0 || got.CurrentFile != "" {
t.Errorf("app-scoped counters survived the phase switch: %+v — the card would show the last "+
"app's finished numbers against retention work", got)
}
// Starting another app must clear the phase again, or the card would stay on „Karbantartás".
st.setApp("immich")
if p := st.snapshot(); p.Phase != "" || p.CurrentApp != "immich" {
t.Errorf("after setApp: phase=%q app=%q, want phase cleared and app set", p.Phase, p.CurrentApp)
}
}
// TestParseResticStatusClampsPercent — restic has been seen to report percent_done slightly above 1
// near completion. A bar wider than its track is a visible bug.
func TestParseResticStatusClampsPercent(t *testing.T) {