From bef467febe700cc9b25220e5c07d0c354df675d8 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 09:42:32 +0100 Subject: [PATCH] H-revert: the node agent starts a held revert on the live data when its want says revert live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/agent/agent.go | 3 ++- internal/agent/kept.go | 8 +++++++- internal/agent/kept_test.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 91c48506..33c2ff60 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -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)) diff --git a/internal/agent/kept.go b/internal/agent/kept.go index 052e4bc9..1cbc3ef4 100644 --- a/internal/agent/kept.go +++ b/internal/agent/kept.go @@ -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 diff --git a/internal/agent/kept_test.go b/internal/agent/kept_test.go index 31b0272c..cd671f79 100644 --- a/internal/agent/kept_test.go +++ b/internal/agent/kept_test.go @@ -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()}},