host: root never answers published without a persisted receipt (Task 7b fix round 1, item 1)
A receipt that fails to persist is now errHeldReleaseReceipt (HTTP 500), not success. The agent asks again; with no receipt the retry runs the whole path, held to the same render journal and the same selection checks, and publishes the same release, until a receipt persists and a replay is answered from it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+62
−4
internal/host/movesheldrelease.go+6 −0internal/host/movesheldrelease_root.go+8 −4internal/host/movesheldrelease_test.go+48 −0
diff --git a/internal/host/movesheldrelease.go b/internal/host/movesheldrelease.go| index 779101ba6..24afc6b72 100644 |
| --- a/internal/host/movesheldrelease.go |
| +++ b/internal/host/movesheldrelease.go |
| @@ -74,6 +74,12 @@ var ( |
| // and the agent asks again on a later pass. |
| errHeldReleaseNotYet = errors.New("moves: held release: the held render's hash is not journalled yet") |
| errHeldReleaseUnsafe = errors.New("moves: held release: the render journal is not a regular file with one link and a bounded size") |
| + // errHeldReleaseReceipt is a publication root made whose receipt did not |
| + // persist (fix round 1, item 1): root never answers published without the |
| + // evidence a replay is answered from, so the agent asks again, and the |
| + // retry runs the whole path (the same render, the same selection checks) |
| + // and publishes the same release. |
| + errHeldReleaseReceipt = errors.New("moves: held release: published, but root's receipt did not persist") |
| ) |
| // heldReleaseRequest is one request. Kind, Phase, Incarnation, Stamp, |
diff --git a/internal/host/movesheldrelease_root.go b/internal/host/movesheldrelease_root.go| index 65f87529c..508d22b6c 100644 |
| --- a/internal/host/movesheldrelease_root.go |
| +++ b/internal/host/movesheldrelease_root.go |
| @@ -373,7 +373,8 @@ func publishVersion(c *hostclaim.Claim, m *provision.MoveState, box string) (str |
| // be the request's, and runs release.MaterializeHeldUnder inside it with |
| // the held-stage lock (bind, swap, re-hash and rollback, the lock a leaf |
| // inside Work.With, which re-checks the claim under the hostguard right |
| -// before the swap). The receipt is written last. |
| +// before the swap). The receipt is written last, and the answer is |
| +// published only once it has persisted (errHeldReleaseReceipt otherwise). |
| func (h *movesHeldReleaseRoot) publishRelease(ctx context.Context, actor hostbox.Actor, q heldReleaseRequest) (heldReleaseResponse, error) { |
| if h.replayed(q) { |
| return heldReleaseResponse{Schema: heldReleaseSchema, RequestID: q.RequestID, Outcome: heldReleaseOutcomePublished, Replayed: true}, nil |
| @@ -431,9 +432,12 @@ func (h *movesHeldReleaseRoot) publishRelease(ctx context.Context, actor hostbox |
| } |
| } |
| if err := h.writeReceipt(q); err != nil { |
| - // The publication stands; a replay without a receipt runs the |
| - // whole path again and publishes the same release. |
| - log.Printf("moves: %s: held release %s published, receipt not written: %v", q.Host, q.Version, err) |
| + // Never answered as published without the receipt a replay is |
| + // answered from (fix round 1, item 1). The agent asks again; with |
| + // no receipt the retry runs this whole path, so it is held to the |
| + // same render journal and the same selection checks and publishes |
| + // the same release, and it errs again until a receipt persists. |
| + return heldReleaseResponse{}, fmt.Errorf("%w: %v", errHeldReleaseReceipt, err) |
| } |
| log.Printf("moves: %s: held release %s published for %s at %s %q", q.Host, q.Version, q.MoveID, q.Kind, q.Phase) |
| return heldReleaseResponse{Schema: heldReleaseSchema, RequestID: q.RequestID, Outcome: heldReleaseOutcomePublished}, nil |
diff --git a/internal/host/movesheldrelease_test.go b/internal/host/movesheldrelease_test.go| index 6a0283090..b74852277 100644 |
| --- a/internal/host/movesheldrelease_test.go |
| +++ b/internal/host/movesheldrelease_test.go |
| @@ -540,6 +540,54 @@ func TestHeldReleaseRootReplayAndCrashRecovery(t *testing.T) { |
| } |
| }) |
| + t.Run("a receipt that does not persist is no success", func(t *testing.T) { |
| + // Fix round 1, item 1: root answers published only once the receipt |
| + // that answers a replay has persisted. Without it the answer is an |
| + // error; the next identical request runs the whole path again (the |
| + // same render, the same selection checks), publishing the same |
| + // release, and still errs until a receipt persists. Then a replay |
| + // is answered from the receipt with no selection. |
| + f := newHRFixture(t) |
| + blocker := filepath.Join(t.TempDir(), "not-a-dir") |
| + if err := os.WriteFile(blocker, nil, 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + good := f.root.ReceiptDir |
| + f.root.ReceiptDir = filepath.Join(blocker, "held-release") |
| + q := f.request(heldReleasePublish) |
| + for i := 1; i <= 2; i++ { |
| + resp, err := f.root.Serve(ctx, hrPeer, q) |
| + if err == nil || resp.Outcome != "" { |
| + t.Fatalf("attempt %d with no receipt: %+v %v, want an error", i, resp, err) |
| + } |
| + if !errors.Is(err, errHeldReleaseReceipt) { |
| + t.Fatalf("attempt %d: %v, want errHeldReleaseReceipt", i, err) |
| + } |
| + if f.sel.count() != i || len(f.sel.ids) != i || f.sel.ids[i-1] != (hostwrite.Identity{Release: "v7"}) { |
| + t.Fatalf("attempt %d: selections %d under %v, want each attempt through the whole path", i, f.sel.count(), f.sel.ids) |
| + } |
| + } |
| + // A retry is held to the same render: a changed journal refuses it |
| + // before any selection. |
| + f.journal(t, p1ID, hrOther) |
| + if _, err := f.root.Serve(ctx, hrPeer, q); err == nil || f.sel.count() != 2 { |
| + t.Fatalf("a retry under another render: %v, selections %d", err, f.sel.count()) |
| + } |
| + f.journal(t, p1ID, hrRendered) |
| + f.root.ReceiptDir = good |
| + resp, err := f.root.Serve(ctx, hrPeer, q) |
| + if err != nil || resp.Outcome != heldReleaseOutcomePublished || resp.Replayed { |
| + t.Fatalf("once the receipt persists: %+v %v", resp, err) |
| + } |
| + again, err := f.root.Serve(ctx, hrPeer, q) |
| + if err != nil || !again.Replayed || f.sel.count() != 3 { |
| + t.Fatalf("the replay: %+v %v, selections %d", again, err, f.sel.count()) |
| + } |
| + if v, _ := release.LiveVersion(f.opt, p1Host); v != "v7" { |
| + t.Fatalf("live = %q", v) |
| + } |
| + }) |
| + |
| t.Run("a crash between the publication and the receipt", func(t *testing.T) { |
| f := newHRFixture(t) |
| crashed := false |