host: published layout intent distinguishes unreadable from none
startupLayoutIntent and layoutLegWith computed layout.IntentUnreadable locally on a failed read or an unknown mode, but never published it: layoutIntentSeen was left untouched, so a box whose every read failed reported "" (none) even while recovery held a completed revert fenced under exactly that intent. Publish the marker on every failure and unknown-mode branch in both functions; a later successful read still replaces it, and an absent or inapplicable intent still publishes "".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2 files changed,
+53
−8
internal/host/agent_layout_test.go+39 −3internal/host/layoutleg.go+14 −5
CI passed — run details
diff --git a/internal/host/agent_layout_test.go b/internal/host/agent_layout_test.go| index 8d9aab777..f8f397952 100644 |
| --- a/internal/host/agent_layout_test.go |
| +++ b/internal/host/agent_layout_test.go |
| @@ -82,7 +82,10 @@ func TestLayoutLegConvergesOnlyWhenTheIntentAppliesToThisBox(t *testing.T) { |
| } |
| // An intent this binary cannot read, or a request whose mode it does not |
| -// know, moves nothing, and the last intent it did read stands. |
| +// know, moves nothing, and publishes layout.IntentUnreadable — overwriting |
| +// whatever mode was last seen, since a read that failed just now is not |
| +// proof the fleet still wants that mode. A later successful read replaces |
| +// the marker the same way (TestStartupLayoutIntentPublishesUnreadable). |
| func TestLayoutLegDoesNothingWhenTheIntentIsUnreadable(t *testing.T) { |
| for _, tc := range []struct { |
| name string |
| @@ -108,8 +111,8 @@ func TestLayoutLegDoesNothingWhenTheIntentIsUnreadable(t *testing.T) { |
| if called { |
| t.Fatal("converged under an intent it could not read") |
| } |
| - if got := lastLayoutIntent(); got != release.LayoutModeFlat { |
| - t.Fatalf("intent seen = %q, want the last one read (flat)", got) |
| + if got := lastLayoutIntent(); got != string(layout.IntentUnreadable) { |
| + t.Fatalf("intent seen = %q, want unreadable (the last read failed, not the last mode read)", got) |
| } |
| }) |
| } |
| @@ -274,14 +277,23 @@ func TestStartupLayoutIntent(t *testing.T) { |
| if got := startupLayoutIntent(ctx, nil, errors.New("no bucket"), "kass-1"); got != layout.IntentUnreadable { |
| t.Fatalf("no store: %q", got) |
| } |
| + if got := lastLayoutIntent(); got != string(layout.IntentUnreadable) { |
| + t.Fatalf("intent seen after no store = %q, want unreadable", got) |
| + } |
| s := release.NewDirStore(t.TempDir()) |
| if got := startupLayoutIntent(ctx, s, nil, "kass-1"); got != "" { |
| t.Fatalf("no object: %q", got) |
| } |
| + if got := lastLayoutIntent(); got != "" { |
| + t.Fatalf("intent seen after no object = %q, want none (a successful read replaces the marker)", got) |
| + } |
| appendIntent(t, s, release.LayoutModeNested, "kass-2") |
| if got := startupLayoutIntent(ctx, s, nil, "kass-1"); got != "" { |
| t.Fatalf("another box's intent: %q", got) |
| } |
| + if got := lastLayoutIntent(); got != "" { |
| + t.Fatalf("intent seen after another box's intent = %q, want none", got) |
| + } |
| appendIntent(t, s, release.LayoutModeNested, "") |
| if got := startupLayoutIntent(ctx, s, nil, "kass-1"); got != layout.ModeNested { |
| t.Fatalf("fleet intent: %q", got) |
| @@ -300,6 +312,30 @@ func TestStartupLayoutIntent(t *testing.T) { |
| if got := startupLayoutIntent(ctx, bad, nil, "kass-1"); got != layout.IntentUnreadable { |
| t.Fatalf("%s: %q", body, got) |
| } |
| + // The prior call published "nested"; this failed read must overwrite |
| + // it with the marker, not leave the stale mode standing. |
| + if got := lastLayoutIntent(); got != string(layout.IntentUnreadable) { |
| + t.Fatalf("%s: intent seen = %q, want unreadable to overwrite the last known mode", body, got) |
| + } |
| + noteLayoutIntent(release.LayoutModeNested) // restore, for the next iteration's overwrite check |
| + } |
| +} |
| + |
| +// TestStartupLayoutIntentPublishesUnreadable pins the fix on its own: a box |
| +// whose every read fails at startup must report layout.IntentUnreadable, not |
| +// "" (none) — recovery already holds a completed revert fenced under exactly |
| +// that intent, and the published status must not contradict it. |
| +func TestStartupLayoutIntentPublishesUnreadable(t *testing.T) { |
| + resetLayoutGlobals(t) |
| + ctx := context.Background() |
| + if got := lastLayoutIntent(); got != "" { |
| + t.Fatalf("intent seen before any read = %q, want none", got) |
| + } |
| + if got := startupLayoutIntent(ctx, nil, errors.New("no deploy store configured"), "kass-1"); got != layout.IntentUnreadable { |
| + t.Fatalf("startupLayoutIntent = %q, want unreadable", got) |
| + } |
| + if got := lastLayoutIntent(); got != string(layout.IntentUnreadable) { |
| + t.Fatalf("intent seen = %q, want unreadable, not none", got) |
| } |
| } |
diff --git a/internal/host/layoutleg.go b/internal/host/layoutleg.go| index cd018655c..caf6dff18 100644 |
| --- a/internal/host/layoutleg.go |
| +++ b/internal/host/layoutleg.go |
| @@ -34,9 +34,11 @@ var armedLayoutPass atomic.Pointer[layout.Pass] |
| // a no-op and the edge's own 2s registry poll picks up a flipped row. |
| var layoutReload atomic.Pointer[func()] |
| -// layoutIntentSeen is the last layout intent mode the agent read for this |
| -// box: "" when none is recorded or none addresses it. A read that fails |
| -// leaves it as it was, because a failed read says nothing about the intent. |
| +// layoutIntentSeen is the last outcome of a layout intent read for this box: |
| +// "" when none is recorded or none addresses it, layout.IntentUnreadable |
| +// when the last read failed or named a mode this binary does not know. A |
| +// later successful read replaces whichever of the two it holds, so this |
| +// only ever needs to keep "no intent" apart from "we could not tell". |
| var layoutIntentSeen atomic.Pointer[string] |
| // lastLayoutIntent is the mode layoutIntentSeen holds, "" before any read. |
| @@ -137,6 +139,7 @@ const layoutIntentTimeout = 30 * time.Second |
| func startupLayoutIntent(ctx context.Context, store release.Store, storeErr error, boxLabel string) layout.Mode { |
| if storeErr != nil { |
| log.Printf("layout: intent -> unreadable: deploy store: %v", storeErr) |
| + noteLayoutIntent(string(layout.IntentUnreadable)) |
| return layout.IntentUnreadable |
| } |
| ctx, cancel := context.WithTimeout(ctx, layoutIntentTimeout) |
| @@ -145,12 +148,14 @@ func startupLayoutIntent(ctx context.Context, store release.Store, storeErr erro |
| switch { |
| case err != nil: |
| log.Printf("layout: intent -> unreadable: %v", err) |
| + noteLayoutIntent(string(layout.IntentUnreadable)) |
| return layout.IntentUnreadable |
| case !found: |
| noteLayoutIntent(release.LayoutModeNone) |
| return "" |
| case !release.ValidLayoutMode(in.Mode): |
| log.Printf("layout: intent %s -> unreadable: mode %q is not flat or nested", in.ID, in.Mode) |
| + noteLayoutIntent(string(layout.IntentUnreadable)) |
| return layout.IntentUnreadable |
| } |
| noteLayoutIntent(in.Mode) |
| @@ -220,19 +225,23 @@ func runLayoutLeg(ctx context.Context, store release.Store, boxLabel string) { |
| // layoutLegWith is the leg's decision: read the intent, and converge only |
| // when a request addresses this box with a mode this binary knows. An |
| -// intent that cannot be read moves nothing; the next serial move reads it |
| -// again. |
| +// intent that cannot be read moves nothing and publishes |
| +// layout.IntentUnreadable, overwriting whatever mode was last seen, since a |
| +// read that just failed is not proof the fleet still wants it; the next |
| +// serial move reads it again. |
| func layoutLegWith(ctx context.Context, store release.Store, boxLabel string, converge func(context.Context, layout.Mode) []layout.Result) { |
| in, found, err := release.LoadLayoutIntent(ctx, store, boxLabel) |
| switch { |
| case err != nil: |
| log.Printf("layout: intent -> unreadable: %v; not converging this move", err) |
| + noteLayoutIntent(string(layout.IntentUnreadable)) |
| return |
| case !found: |
| noteLayoutIntent(release.LayoutModeNone) |
| return |
| case !release.ValidLayoutMode(in.Mode): |
| log.Printf("layout: intent %s -> unreadable: mode %q is not flat or nested; not converging this move", in.ID, in.Mode) |
| + noteLayoutIntent(string(layout.IntentUnreadable)) |
| return |
| } |
| noteLayoutIntent(in.Mode) |