carlos / platform Public

console: a declined box's down hold says it cannot run two builds; one retry refusal for page and door

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 65d4e1350c9f5eb0dc66fdaac78550dd6f48d34b parent 6e6ad00
5 files changed, +95 −50
  • copy-review/strings.json +8 −0
  • internal/console/apiretry.go +2 −5
  • internal/console/browserretry_test.go +19 −2
  • internal/console/instancepage.go +23 −6
  • internal/console/swapcopy.go +43 −37
diff --git a/copy-review/strings.json b/copy-review/strings.json
index 1452cb144..a8f2c9db2 100644
--- a/copy-review/strings.json
+++ b/copy-review/strings.json
@@ -7,6 +7,14 @@
"context": "The \"Failure hold\" row on an instance's page when the instance's box cannot run two builds side by side (it was not set up for zero-downtime deploys) but still remembers that a build failed. %s is the failed build. Today's hold row says the instance \"stays on\" the old build until you retry, which is untrue here: this box refuses to start the instance on the held build at all. A Retry button sits below the row. The reader is a member looking at one of their instances.",
"notes": "Draft. Two sentences after the build, then the next step, in the same voice as the existing hold row (\"Retry it, or ship a newer build.\" is its closing line verbatim). No reason is given because a box in this state does not report one. No em dashes."
},
+ {
+ "id": "zdd-readiness2.hold-declined-down",
+ "section": "Instance page: a box that cannot run two builds side by side",
+ "label": "Failure hold row (declined box, instance left down)",
+ "text": "Build %s failed on this box, and nothing is serving this instance. This box cannot run two builds side by side. Roll back with carlos rollback, or retry it.",
+ "context": "The \"Failure hold\" row on an instance's page when the instance's box cannot run two builds side by side and the failed build left the instance down, so nothing is serving it. %s is the failed build. The approved down-row says the build failed \"in hold mode\", which is untrue here: this box stops then starts whatever the member's swap policy is. A Retry button sits below the row when the box can take one. The reader is a member looking at one of their instances.",
+ "notes": "Draft. Same shape as the approved down-row, without \"in hold mode\", plus the one-sentence reason this box cannot hold. Closing line is the approved down-row's verbatim. No em dashes."
+ },
{
"id": "zdd-readiness2.retry-line-declined",
"section": "Instance page: a box that cannot run two builds side by side",
diff --git a/internal/console/apiretry.go b/internal/console/apiretry.go
index 7eea13326..4938edb59 100644
--- a/internal/console/apiretry.go
+++ b/internal/console/apiretry.go
@@ -91,11 +91,8 @@ func (s *Server) retryHostBuild(ctx context.Context, acct *Account, app, host st
if !ro.retryable() {
return heldRestriction{}, errRetryNotHeld
}
- switch {
- case ro.retryV == 0 || ro.heldBook == "":
- return heldRestriction{}, errRetryUnsupported
- case ro.heldEpoch == 0:
- return heldRestriction{}, errRetryUnsaved
+ if err := ro.retryRefusal(); err != nil {
+ return heldRestriction{}, err
}
return heldRestriction{build: ro.HeldBy, book: ro.heldBook, epoch: ro.heldEpoch, declined: ro.Unavailable != ""}, nil
}
diff --git a/internal/console/browserretry_test.go b/internal/console/browserretry_test.go
index 009265091..f8981d266 100644
--- a/internal/console/browserretry_test.go
+++ b/internal/console/browserretry_test.go
@@ -215,7 +215,7 @@ func TestInstancePageOfADeclinedBoxShowsTheHoldAndRetry(t *testing.T) {
host, hold string
}{
{heldHost, fmt.Sprintf(swapCopy.HoldDeclinedText, "v2")},
- {downHost, fmt.Sprintf(swapCopy.HoldDownText, "v2")},
+ {downHost, fmt.Sprintf(swapCopy.HoldDeclinedDownText, "v2")},
} {
code, _, body := get(t, c, fx.u("/apps/kass/instances/"+tc.host))
if code != http.StatusOK {
@@ -226,7 +226,7 @@ func TestInstancePageOfADeclinedBoxShowsTheHoldAndRetry(t *testing.T) {
t.Errorf("%s: page lacks %q", tc.host, want)
}
}
- for _, bad := range []string{"it failed ()", "stays on", "stays on until"} {
+ for _, bad := range []string{"it failed ()", "stays on", "stays on until", "hold mode"} {
if strings.Contains(body, bad) {
t.Errorf("%s: a declined box's page must not say %q", tc.host, bad)
}
@@ -295,3 +295,20 @@ func TestInstancePageOfAnOlderBoxKeepsTheHoldAndHidesRetry(t *testing.T) {
t.Error("a box that cannot take a retry must not be offered one")
}
}
+
+// A report with no book identity alone (retry_v and the epoch present) hides
+// the form, since the door would refuse it, but keeps the hold row.
+func TestInstancePageWithNoHeldBookHidesRetryAndKeepsTheHold(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.addRoute(t, registry.Route{Host: heldHost, App: "kass", Account: fx.sqid, Kind: "instance", Status: "ready", Version: "v2", Channel: "stable"})
+ sibling(t, fx, "kass", heldHost, "stable")
+ writeRetryReport(t, fx, heldHost, "", status.HostRetryV, heldEpoch, "")
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/kass/instances/"+heldHost))
+ if !strings.Contains(body, swapCopy.Hold) || !strings.Contains(body, "carlos promote --app") {
+ t.Error("the hold row and the promote line must stay")
+ }
+ if strings.Contains(body, "/retry\"") {
+ t.Error("a report with no book identity must not be offered Retry")
+ }
+}
diff --git a/internal/console/instancepage.go b/internal/console/instancepage.go
index 35dfb0e59..72cd4bc3d 100644
--- a/internal/console/instancepage.go
+++ b/internal/console/instancepage.go
@@ -41,11 +41,25 @@ func (r *rolloutView) retryable() bool {
return r != nil && r.HeldBy != "" && (r.Unavailable == "" || rolloutwords.UnavailableDeclines(r.Unavailable))
}
+// retryRefusal is why a retryable host's report cannot take a signed grant
+// yet: errRetryUnsupported for a box with no retry_v or no book identity,
+// errRetryUnsaved for a held host at epoch 0, whose book owes a save. nil
+// means the door may sign. The page and retryHostBuild both ask it, so they
+// cannot drift.
+func (r *rolloutView) retryRefusal() error {
+ switch {
+ case r.retryV == 0 || r.heldBook == "":
+ return errRetryUnsupported
+ case r.heldEpoch == 0:
+ return errRetryUnsaved
+ }
+ return nil
+}
+
// CanRetry is whether the page offers Retry: the host is retryable, and the
-// report lets the door sign a grant the box accepts (a retry_v, a saved
-// epoch and the book's identity; the same test retryHostBuild refuses on).
+// report lets the door sign a grant the box accepts (retryRefusal is nil).
func (r *rolloutView) CanRetry() bool {
- return r.retryable() && r.retryV >= 1 && r.heldEpoch != 0 && r.heldBook != ""
+ return r.retryable() && r.retryRefusal() == nil
}
// CanRetry forwards to the rollout view, since the template's dot is the
@@ -65,12 +79,15 @@ func (d *instanceDetail) HoldText() string {
if !r.retryable() {
return ""
}
- if r.Attempt == status.AttemptDown {
- return fmt.Sprintf(swapCopy.HoldDownText, r.HeldBy)
- }
if r.Unavailable != "" {
+ if r.Attempt == status.AttemptDown {
+ return fmt.Sprintf(swapCopy.HoldDeclinedDownText, r.HeldBy)
+ }
return fmt.Sprintf(swapCopy.HoldDeclinedText, r.HeldBy)
}
+ 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 6d362b136..6c9fa027a 100644
--- a/internal/console/swapcopy.go
+++ b/internal/console/swapcopy.go
@@ -5,37 +5,38 @@ package console
// serving facts, failure hold and Retry action on an instance page. Every
// string is approved copy, pinned by the zero-downtime copy record.
type swapCopyStrings struct {
- Heading string
- Intro string
- SwapLabel string
- OverlapOption string
- HoldOption string
- ConcurrencyLabel string
- MigrationsNote string
- Saved string
- Invalid string
- StoreFailed string
- RateLimited string
- Unread string
- NoBucket string
- Installed string
- Serving string
- Hold string
- HoldText string
- HoldDownText string
- HoldDeclinedText string
- PromoteRetry string
- RetryButton string
- Rolling string
- Asleep string
- RetryTitle string
- RetryLine string
- RetryLineDeclined string
- RetryDone string
- RetryFailed string
- RetryLimited string
- NotHeld string
- Audit string
+ Heading string
+ Intro string
+ SwapLabel string
+ OverlapOption string
+ HoldOption string
+ ConcurrencyLabel string
+ MigrationsNote string
+ Saved string
+ Invalid string
+ StoreFailed string
+ RateLimited string
+ Unread string
+ NoBucket string
+ Installed string
+ Serving string
+ Hold string
+ HoldText string
+ HoldDownText string
+ HoldDeclinedText string
+ HoldDeclinedDownText string
+ PromoteRetry string
+ RetryButton string
+ Rolling string
+ Asleep string
+ RetryTitle string
+ RetryLine string
+ RetryLineDeclined string
+ RetryDone string
+ RetryFailed string
+ RetryLimited string
+ NotHeld string
+ Audit string
}
var swapCopy = swapCopyStrings{
@@ -64,12 +65,17 @@ var swapCopy = swapCopyStrings{
// holds the failed build too, but it will not start the instance on it,
// so "stays on" would be untrue. The decline's Reason is empty there.
HoldDeclinedText: "Build %s failed on this box. This box cannot run two builds side by side, so it will not start this instance on that build. Retry it, or ship a newer build.", // TODO-COPY(zdd-readiness2.hold-declined)
- PromoteRetry: "To try it again on every box: carlos promote --app %s --retry %s %s",
- RetryButton: "Retry",
- Rolling: "Build %s is installed and rolling out. Build %s serves until the new one passes its health check.",
- Asleep: "Build %s is installed and starts on the next request.",
- RetryTitle: "Retry build {build} on {host}?",
- 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.",
+ // HoldDeclinedDownText is the hold row for a declined box whose failed
+ // attempt left the instance down: such a box stops then starts because it
+ // cannot run two builds side by side, whatever the member's swap policy,
+ // so it is not "in hold mode".
+ HoldDeclinedDownText: "Build %s failed on this box, and nothing is serving this instance. This box cannot run two builds side by side. Roll back with carlos rollback, or retry it.", // TODO-COPY(zdd-readiness2.hold-declined-down)
+ PromoteRetry: "To try it again on every box: carlos promote --app %s --retry %s %s",
+ RetryButton: "Retry",
+ Rolling: "Build %s is installed and rolling out. Build %s serves until the new one passes its health check.",
+ Asleep: "Build %s is installed and starts on the next request.",
+ RetryTitle: "Retry build {build} on {host}?",
+ 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.",
// RetryLineDeclined is the confirmation for a declined box: no canary
// runs there, the next start runs the build as it always has.
RetryLineDeclined: "This clears the failure hold on {host}. Its box cannot run two builds side by side, so its next start runs {build} the old way, with no canary.", // TODO-COPY(zdd-readiness2.retry-line-declined)