carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 1676eb69161c9a98633a3dd062e1f6f3da4f35ad parent 0821fa9
3 files changed, +62 −4
  • internal/host/movesheldrelease.go +6 −0
  • internal/host/movesheldrelease_root.go +8 −4
  • internal/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