carlos / platform Public

zdd-6 final fix wave 1: W4 drafts that state no false behaviour

zdd.event.swapping is mode-neutral (it is emitted for hold swaps and worker replacements too); zdd.instance.retry-line drops the fallback promise; a hold-mode instance left down gets zdd.instance.hold-down-text instead of a hold row that says it stays on a build. Astra plan 6 R1 #8.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 2410a5b404ee40db2a7d58822439a82dda8ff20f parent cb24f42
5 files changed, +133 −3
  • internal/console/instancepage.go +6 −1
  • internal/console/swapcopy.go +7 −1
  • internal/console/swapcopy_truth_test.go +70 −0
  • internal/rollout/events_mode_test.go +46 −0
  • internal/rolloutwords/words.go +4 −1
diff --git a/internal/console/instancepage.go b/internal/console/instancepage.go
index 586ca9f2c..4dcd1d9a6 100644
--- a/internal/console/instancepage.go
+++ b/internal/console/instancepage.go
@@ -33,12 +33,17 @@ type instanceDetail struct {
// HoldText is the Failure hold row (spec §9.2), or "" when nothing holds
// this host back (and always for a box that could not arm its scheduler,
-// whose view carries no hold).
+// whose view carries no hold). A hold-mode failure that left the instance
+// down (attempt down) has nothing serving it, so its row says so rather
+// than naming a build it "stays on" (Astra plan 6 R1 #8).
func (d *instanceDetail) HoldText() string {
r := d.Rollout
if r == nil || r.Unavailable != "" || r.HeldBy == "" {
return ""
}
+ if r.Attempt == status.AttemptDown {
+ return fmt.Sprintf(swapCopy.HoldDownText, r.HeldBy)
+ }
stays := r.Serving
if stays == "" {
stays = r.Good
diff --git a/internal/console/swapcopy.go b/internal/console/swapcopy.go
index 705cf3959..d21fc5c99 100644
--- a/internal/console/swapcopy.go
+++ b/internal/console/swapcopy.go
@@ -22,6 +22,7 @@ type swapCopyStrings struct {
Serving string
Hold string
HoldText string
+ HoldDownText string
PromoteRetry string
RetryButton string
Rolling string
@@ -70,6 +71,11 @@ var swapCopy = swapCopyStrings{
Hold: "Failure hold",
// TODO-COPY zdd.instance.hold-text
HoldText: "Build %s failed on this box because %s. This instance stays on %s until you retry it or ship a newer build.",
+ // HoldDownText is the hold row when the failed attempt left the instance
+ // down (hold mode, attempt down): nothing serves it, so it stays on
+ // nothing (Astra plan 6 R1 #8).
+ // TODO-COPY zdd.instance.hold-down-text
+ HoldDownText: "Build %s failed in hold mode on this box, and nothing is serving this instance. Roll back with carlos rollback, or retry it.",
// TODO-COPY zdd.instance.promote-retry
PromoteRetry: "To try it again on every box: carlos promote --app %s --retry %s %s",
// TODO-COPY zdd.instance.retry-button
@@ -81,7 +87,7 @@ var swapCopy = swapCopyStrings{
// TODO-COPY zdd.instance.retry-title
RetryTitle: "Retry build {build} on {host}?",
// TODO-COPY zdd.instance.retry-line
- RetryLine: "This clears the failure hold on {host}. Its next attempt at {build} runs as a canary. If that fails too, {host} stays on its last good build.",
+ RetryLine: "This clears the failure hold on {host}. Its next attempt at {build} runs as a canary. If that fails too, {host} is held again.",
// TODO-COPY zdd.instance.retry-done
RetryDone: "Retry requested for {host}.",
// TODO-COPY zdd.instance.retry-failed
diff --git a/internal/console/swapcopy_truth_test.go b/internal/console/swapcopy_truth_test.go
new file mode 100644
index 000000000..a93f5b106
--- /dev/null
+++ b/internal/console/swapcopy_truth_test.go
@@ -0,0 +1,70 @@
+package console
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/rolloutwords"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// Astra plan 6 R1 #8: the retry confirmation promises no fallback. A retry
+// that fails in hold mode leaves the instance down, so "stays on its last
+// good build" was false there; the host is held again either way.
+func TestBrowserRetryConfirmationPromisesNoFallback(t *testing.T) {
+ fx := heldFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ code, _, body := postForm(t, c, fx.u("/apps/kass/instances/"+heldHost+"/retry"), url.Values{"csrf": {csrf}})
+ want := "This clears the failure hold on " + heldHost + ". Its next attempt at v2 runs as a canary. If that fails too, " + heldHost + " is held again."
+ if code != http.StatusOK || !strings.Contains(body, want) {
+ t.Fatalf("confirm = %d, lacks %q", code, want)
+ }
+ if strings.Contains(body, "last good build") {
+ t.Fatal("the confirmation still promises a fallback to the last good build")
+ }
+}
+
+// Astra plan 6 R1 #8: a hold-mode failure leaves nothing serving
+// (attempt down), so the page's hold row must not say the instance "stays
+// on" a build there. Both renders: a held instance that serves its good
+// build keeps the hold text; a down one gets the down text.
+func TestInstancePageHoldTextTellsDownFromHeld(t *testing.T) {
+ fx := newConsoleFixture(t)
+ const down = "down.kass.carlosapp.dev"
+ for _, h := range []string{heldHost, down} {
+ fx.addRoute(t, registry.Route{Host: h, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"})
+ sibling(t, fx, "kass", h, "stable")
+ }
+ box := status.CollectBox(nil, status.Probes{MachineID: "box1"}, time.Now())
+ box.Metrics = &status.BoxMetrics{Rollout: &status.RolloutReport{Hosts: map[string]status.HostRollout{
+ heldHost: {Account: fx.sqid, App: "kass", Installed: "v2", Serving: "v1", Good: "v1", Awake: true, HeldBy: "v2", Reason: status.ReasonHealth},
+ down: {Account: fx.sqid, App: "kass", Installed: "v2", Good: "v1", Attempt: status.AttemptDown, AttemptBuild: "v2",
+ HeldBy: "v2", Reason: status.ReasonExit},
+ }}}
+ if err := status.WriteBox(context.Background(), fx.rel, box); err != nil {
+ t.Fatal(err)
+ }
+ c := signInPaul(t, fx)
+ _, _, held := get(t, c, fx.u("/apps/kass/instances/"+heldHost))
+ if want := fmt.Sprintf(swapCopy.HoldText, "v2", rolloutwords.Reason(status.ReasonHealth), "v1"); !strings.Contains(held, want) {
+ t.Errorf("held page lacks %q", want)
+ }
+ _, _, body := get(t, c, fx.u("/apps/kass/instances/"+down))
+ want := "Build v2 failed in hold mode on this box, and nothing is serving this instance. Roll back with carlos rollback, or retry it."
+ if !strings.Contains(body, want) {
+ t.Errorf("down page lacks %q", want)
+ }
+ if strings.Contains(body, "stays on") {
+ t.Error("the down page says the instance stays on a build")
+ }
+ if !strings.Contains(body, `/instances/`+down+`/retry"`) {
+ t.Error("the down page lost its Retry")
+ }
+}
diff --git a/internal/rollout/events_mode_test.go b/internal/rollout/events_mode_test.go
new file mode 100644
index 000000000..2b6089bad
--- /dev/null
+++ b/internal/rollout/events_mode_test.go
@@ -0,0 +1,46 @@
+package rollout
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/rolloutwords"
+)
+
+// Astra plan 6 R1 #8: the swapping event is emitted for every attempt, a
+// hold-mode swap and a socketless worker's stop-then-start replacement as
+// much as an overlap swap, so its words must not say the new build starts
+// beside the running one. Driven through the real emit sites (scheduler
+// execute, executeWorker).
+func TestTheSwappingEventIsModeNeutral(t *testing.T) {
+ for name, h := range map[string]*harness{
+ "hold swap": func() *harness { h := newHarness(t, "a"); h.swap = "hold"; return h }(),
+ "worker replacement": workerHarness(t, "a"),
+ } {
+ h.stage("v2")
+ h.install("v2", "a")
+ h.pass()
+ want := "a: " + fmt.Sprintf(rolloutwords.EventSwapping, "v2", rolloutwords.Cause("release"))
+ waitFor(t, name+": the swapping event", func() bool { return h.events.has(want) })
+ waitFor(t, name+": the attempt", func() bool {
+ calls, _, _ := h.act.snapshot()
+ return len(calls)+len(h.act.replacements()) == 1
+ })
+ calls, _, _ := h.act.snapshot()
+ switch {
+ case name == "hold swap" && (len(calls) != 1 || calls[0].a.Mode != activator.ModeHold):
+ t.Fatalf("%s: swaps %+v, want one in hold mode", name, calls)
+ case name == "worker replacement" && (len(calls) != 0 || len(h.act.replacements()) != 1):
+ t.Fatalf("%s: %d swaps, %d replacements, want one replacement", name, len(calls), len(h.act.replacements()))
+ }
+ h.events.mu.Lock()
+ for _, l := range h.events.lines {
+ if strings.Contains(l, "beside") {
+ t.Errorf("%s: %q says the new build runs beside the old one", name, l)
+ }
+ }
+ h.events.mu.Unlock()
+ }
+}
diff --git a/internal/rolloutwords/words.go b/internal/rolloutwords/words.go
index 7641e1907..58a213a1e 100644
--- a/internal/rolloutwords/words.go
+++ b/internal/rolloutwords/words.go
@@ -54,8 +54,11 @@ func Reason(r string) string {
// Event formats (spec §9.4), one per text function in internal/rollout/events.go
// plus the bounce. TestVerbsAreWhereTheCallersPutThem pins each verb count.
const (
+ // Emitted for every attempt, overlap, hold and a socketless worker's
+ // stop-then-start alike (rollout's execute and executeWorker), so it
+ // says nothing about the mode (Astra plan 6 R1 #8).
// TODO-COPY zdd.event.swapping
- EventSwapping = "starting build %s beside the running one (%s)"
+ EventSwapping = "starting build %s (%s)"
// TODO-COPY zdd.event.swapped
EventSwapped = "now serving build %s. The previous build finished its requests and stopped in %s"
// TODO-COPY zdd.event.swap-failed