v0.297.0: burn-down round 2 — 24 small rows (R-591 R-568 R-567 R-363 R-547 R-10 R-552 R-251 R-104 R-619 R-362 R-675 R-256 R-257 R-240 R-365 R-425 R-565 R-564 R-603 R-454 R-208 R-457-swept) + the banner countdown and deepCopyStack twins; MinAgent 0.131.0
gates / gates (push) Failing after 50s
gates / gates (push) Failing after 50s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -148,8 +148,17 @@ const (
|
||||
OffsiteFailNoUnits OffsiteFailureClass = "no_units" // apps toggled but no recovery unit found on any drive
|
||||
OffsiteFailTransport OffsiteFailureClass = "transport" // network / SFTP auth / host key / timeout
|
||||
OffsiteFailUnknown OffsiteFailureClass = "unknown" // genuinely unclassified — say so rather than guess
|
||||
// OffsiteFailLocked (R-104): the repository is held by a lock that survived both self-heal layers
|
||||
// (pre-run stale unlock, then resticStep's unlock --remove-all + one retry). Known and named, not
|
||||
// "unknown" — an interrupted earlier run is the usual cause.
|
||||
OffsiteFailLocked OffsiteFailureClass = "locked"
|
||||
)
|
||||
|
||||
// ErrOffsiteLocked marks a restic step that still found the repository locked after resticStep's
|
||||
// self-heal (R-104). resticStep wraps it into the step's error, because the lock text lives in the
|
||||
// command OUTPUT, not in the exit error, and would never reach ClassifyOffsiteFailure otherwise.
|
||||
var ErrOffsiteLocked = errors.New("offsite repository is still locked after the self-heal")
|
||||
|
||||
// offsiteRepoURLRe matches the `sftp:user@host:/path` repo reference restic echoes back in its errors.
|
||||
// It is the BACKSTOP, not the primary defence — see sanitiseOffsiteErrorFor.
|
||||
var offsiteRepoURLRe = regexp.MustCompile(`sftp:[^\s"']+`)
|
||||
@@ -203,6 +212,12 @@ func ClassifyOffsiteFailure(err error) OffsiteFailureClass {
|
||||
if errors.Is(err, ErrOffsiteQuota) {
|
||||
return OffsiteFailQuota
|
||||
}
|
||||
// R-104 — before the transport signatures: restic's lock error is a precisely known cause. The
|
||||
// sentinel is what resticStep attaches; the text match catches an error that carries restic's
|
||||
// own output. Pinned by TestR104_SurvivingLockIsNamed.
|
||||
if errors.Is(err, ErrOffsiteLocked) || offboxLockRe.MatchString(err.Error()) {
|
||||
return OffsiteFailLocked
|
||||
}
|
||||
s := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(s, "produced no snapshots"):
|
||||
@@ -248,6 +263,7 @@ func offsiteFailureMessage(t *settings.OffboxTarget, err error, dur time.Duratio
|
||||
OffsiteFailNoUnits: util.Text(lang, "note.offsite.fail_no_units"),
|
||||
OffsiteFailTransport: util.Text(lang, "note.offsite.fail_transport"),
|
||||
OffsiteFailUnknown: util.Text(lang, "note.offsite.fail_unknown"),
|
||||
OffsiteFailLocked: util.Text(lang, "note.offsite.fail_locked"),
|
||||
}[ClassifyOffsiteFailure(err)]
|
||||
if head == "" {
|
||||
head = util.Text(lang, "note.offsite.fail_head")
|
||||
@@ -855,10 +871,14 @@ func (m *Manager) resticStep(ctx context.Context, env, base []string, label stri
|
||||
if uout, uerr := m.runner()(uctx, env, append(append([]string{}, base...), "unlock", "--remove-all")...); uerr != nil {
|
||||
cancel()
|
||||
m.logger.Printf("[WARN] [offbox] unlock --remove-all failed: %v: %s", uerr, truncate(uout))
|
||||
return out, err // surface the original lock error (never loop)
|
||||
return out, fmt.Errorf("%w (%w)", err, ErrOffsiteLocked) // surface the original lock error (never loop); R-104: named
|
||||
}
|
||||
cancel()
|
||||
return m.runner()(ctx, env, full...) // retry exactly ONCE
|
||||
rout, rerr := m.runner()(ctx, env, full...) // retry exactly ONCE
|
||||
if rerr != nil && offboxLockRe.Match(rout) {
|
||||
return rout, fmt.Errorf("%w (%w)", rerr, ErrOffsiteLocked) // R-104: still locked after the self-heal
|
||||
}
|
||||
return rout, rerr
|
||||
}
|
||||
|
||||
// ensureOffboxRepo makes sure the SFTP repo exists: probe `cat config`; if absent, `init` (idempotent —
|
||||
@@ -1164,8 +1184,13 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
|
||||
// is covered until at least one app is toggled.
|
||||
// R-7b: the shares leg counts as coverage — a box whose only cloud content is its shares
|
||||
// must not be told "nothing is selected".
|
||||
// R-240: the sentence no longer opens with „Sikeres" — a run that covered nothing was
|
||||
// congratulating the household on it. The verdict stays `ok` (an unconfigured selection is
|
||||
// not a failure). Still a Hungarian LITERAL and still carrying the lowercase marker
|
||||
// „nincs mentésre jelölt alkalmazás" (R-570: boxes with the older persisted text are
|
||||
// recognised by those words). Pinned by TestR240_ZeroSelectionRunDoesNotSaySuccess.
|
||||
if len(apps) == 0 && !runResult.sharesBackedUp {
|
||||
warns = append(warns, "Sikeres — nincs mentésre jelölt alkalmazás")
|
||||
warns = append(warns, "Ez a futás semmit nem mentett: nincs mentésre jelölt alkalmazás")
|
||||
warnKind = OffboxWarnNoAppsSelected // R-553: the page reads this, not the sentence
|
||||
}
|
||||
// R-234 §7.4 — WHICH apps, WHY, and WHEN. The old sentence said only that N apps "had no
|
||||
@@ -1926,7 +1951,7 @@ func (m *Manager) RestoreOffbox(ctx context.Context, stackName, destDir string)
|
||||
return fmt.Errorf("invalid stack name")
|
||||
}
|
||||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||
return fmt.Errorf("restore dir: %w", err)
|
||||
return m.restoreDirError(destDir, err)
|
||||
}
|
||||
t := m.settings.GetOffboxTarget()
|
||||
base, env := m.offboxBaseArgs(t)
|
||||
|
||||
Reference in New Issue
Block a user