H-revert: the node agent starts a held revert on the live data when its want says revert live

A want with `revert` `live` starts the reverted build with no restore, no aside taken and no catch-up, and
applies as any want; without the field a revert with no restore point stays held.

Builds: design 47 G §710 (G-21-provider-and-revert-ask.md, "On the answer"), §300, §567

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 09:42:32 +01:00
parent a1a328c43e
commit bef467febe
3 changed files with 39 additions and 2 deletions
+2 -1
View File
@@ -1441,7 +1441,8 @@ func (a *Agent) settleOne(ctx context.Context, module string) {
// be swallowed by "it runs what its want names" (issue 1585, the second failure). The operator's answer, again or
// skip, in a revision after the one the hold took, is acted on whatever runs; any other new revision of the want
// is the answer only for a move (ADR 0568 §4), as before.
answered := w.Rev > att.AnsweredRev && (w.Want.Backup == meshwants.WantsByWantNodeModuleValueBackupAgain || w.Want.Backup == meshwants.WantsByWantNodeModuleValueBackupSkip)
answered := w.Rev > att.AnsweredRev && (w.Want.Backup == meshwants.WantsByWantNodeModuleValueBackupAgain || w.Want.Backup == meshwants.WantsByWantNodeModuleValueBackupSkip ||
w.Want.Revert == meshwants.WantsByWantNodeModuleValueRevertLive)
if !answered && (runsWant || (att.AnsweredRev >= w.Rev && att.CompRev == comp.Rev)) {
a.sayOnce("held:"+module, fmt.Sprintf("%s is held for a failed backup before %s: it waits for the operator's "+
"answer, and its want (revision %d) carries none since the hold (revision %d)", module, w.Want.Fp, w.Rev, att.AnsweredRev))
+7 -1
View File
@@ -212,7 +212,7 @@ func (a *Agent) askAndWait(ctx context.Context, h Have, kind string, k KeptMove,
// keptStep takes the restore points a move of a module needs before it applies and restores the ones a revert needs: it
// answers whether the module is held (its have says why, and the running build is kept) and, after a restore, the catch-up
// of the point to call once the reverted build runs. A want that says to go on without a backup asks neither.
// of the point to call once the reverted build runs. A want that says to go on without a backup asks neither, and nor does one that answers a held revert live.
func (a *Agent) keptStep(ctx context.Context, module string, w wantNow, comp compNow, ranBefore bool, others []compositions.Composed) (held bool, catchUps []meshrecovery.CatchUp) {
r := a.env.Recovery
if !ranBefore || r == nil || r.FormChanges == nil || w.Want.Backup == meshwants.WantsByWantNodeModuleValueBackupSkip {
@@ -248,6 +248,12 @@ func (a *Agent) keptStep(ctx context.Context, module string, w wantNow, comp com
if len(take)+len(restore) == 0 {
return false, nil
}
// The operator's answer "live" to a held revert, read from this want and from nothing else: the reverted build starts on the live
// data as it is, with no restore, no aside taken and no catch-up, and applies as any want (design 47 G section 710).
if len(restore) > 0 && w.Want.Revert == meshwants.WantsByWantNodeModuleValueRevertLive {
a.env.Say(fmt.Sprintf("%s starts %s on the live data as it is: its want (revision %d) carries the operator's answer %q to the held revert", module, fp, w.Rev, w.Want.Revert))
return false, nil
}
hold := func(words string, interrupted bool) {
if interrupted || ctx.Err() != nil {
words = BackupInterrupted
+30
View File
@@ -12,7 +12,9 @@ import (
"git.novox.be/novox/mesh-contracts/go/seats/meshrecovery"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/buckets/bucketstest"
"github.com/novox/mesh-host/internal/compositions"
)
@@ -242,6 +244,34 @@ func testAMoveAcrossAFormChange(t *testing.T) {
}
})
t.Run("a held revert, then the want with revert live, starts the older build with no restore, no aside and no catch-up (design 47 G section 710)", func(t *testing.T) {
f := &recoveryFake{changes: down}
b := moveDB(t, f, "a", "b", heldHave)
if h := b.haves.last("db"); !contains(h.Detail, "restore point missing") {
t.Fatalf("the have %+v", h)
}
before := len(b.appliedList())
_, _, dig2 := composition(t, "db", fpOf("b"), true, file("db", "x", "2"))
raw, err := bucketstest.Want(meshwants.WantsByWantNodeModuleValue{Fp: fpOf("b"), Composed: dig2, Revert: meshwants.WantsByWantNodeModuleValueRevertLive,
Words: "the operator answered \"live\" to the revert ask (asking 1)"})
if err != nil {
t.Fatal(err)
}
b.wants.put(meshwants.WantsByWantNodeModuleKey(node, "db"), raw)
eventually(t, "the older build runs on the answer", func() bool { h := b.haves.last("db"); return h.FP == fpOf("b") && h.Phase != "held" })
if got := f.seen(); !slices.Equal(got, []string{"stop", "kept-points", "apply", "apply"}) && len(b.appliedList()) <= before {
t.Fatalf("calls %v", got)
}
for _, c := range f.seen() {
if c == "restore-kept" || c == "snapshot-kept" || c == "catch-up" {
t.Fatalf("calls %v", f.seen())
}
}
if n := strings.Count(strings.Join(f.seen(), " "), "kept-points"); n != 1 {
t.Fatalf("the points were read again on the answer: %v", f.seen())
}
})
t.Run("a revert whose restore fails starts the newer build again and holds the move, saying why (A-113)", func(t *testing.T) {
f := &recoveryFake{changes: down,
points: []meshrecovery.Point{{Module: "db", Kept: "cache", Form: 1, Fingerprint: fp("a"), Snapshot: "snap-1", At: time.Now()}},