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

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:
2026-10-05 20:12:56 +02:00
parent 114ff2761a
commit 1453cfc69b
68 changed files with 3953 additions and 116 deletions
+29 -4
View File
@@ -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)