carlos / platform Public

retries: the box reads grants from the host-retry log and a legacy signed grant from the restart-scope log; neither is seed-skipped at agent restart

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9f27cdfaff2f4ed9b6dcc1520f71d16da58c5b5c parent 6e9e84b
4 files changed, +297 −40
  • internal/host/restart.go +22 −27
  • internal/host/restartscope.go +116 −5
  • internal/host/restartscope_test.go +155 −5
  • internal/release/hostretry.go +4 −3
diff --git a/internal/host/restart.go b/internal/host/restart.go
index 68b641d05..692e1e1a4 100644
--- a/internal/host/restart.go
+++ b/internal/host/restart.go
@@ -844,37 +844,20 @@ func restartRequestLeg(ctx context.Context, routes []registry.Route, store relea
// other for the hourly floor.
scoped, scopeErrs := restartScopePass(ctx, routes, store, mem.scope)
readErrs = append(readErrs, scopeErrs...)
+ // `carlos instances retry` grants ride their own log (release.HostRetryKey),
+ // read on the same move.
+ grants, grantErrs := hostRetryPass(ctx, routes, store, mem)
+ readErrs = append(readErrs, grantErrs...)
if len(readErrs) > 0 {
// ONE line per pass, never one per app: the count carries the scale,
// the first error the cause — they are the same cause in practice.
log.Printf("agent: restart check: %d apps unreadable: %v", len(readErrs), readErrs[0])
}
- // A retry clears a hold only on the console's root-signed grant for this
- // entry (spec §11, Astra impl-6 finding 5): every box can write the log,
- // so an unsigned or foreign entry is dropped here, before anything is
- // cleared. The grant's epoch is checked against the book where the
- // clear is (rollout.Manager.RetryHost, or failureHolds.retryHost on a
- // box that declined the flag but honours its book).
- for _, rq := range retryRequests(routes, scoped) {
- if mem.retryHost == nil {
- log.Printf("agent: retry requested for %s, dropped: rollout manager not armed on this box, so nothing is held to clear", rq.host)
- continue
- }
- g, why := verifyRetry(mem.pub, rq, time.Now())
- if why != "" {
- log.Printf("agent: retry requested for %s, dropped: %s", rq.host, why)
- continue
- }
- if mem.retryHost(rq.host, g.Build, g.Book, g.Epoch) {
- next := "its next attempt runs as a canary"
- if armedRollout() == nil {
- next = "it starts the build as today" // a declined box runs no canary
- }
- log.Printf("agent: retry requested for %s: its failure hold on %s is cleared and %s", rq.host, g.Build, next)
- } else {
- log.Printf("agent: retry for %s: nothing held to clear for %s at epoch %d of book %q (cleared already, a newer failure since the grant, or another book's grant)", rq.host, g.Build, g.Epoch, g.Book)
- }
- }
+ // Each grant is verified, then applied where the hold is (applyRetries):
+ // the host-retry log's, and a signed retry a console built before that
+ // log left in the narrowed one, with the same checks.
+ applyRetries(routes, grants, mem)
+ applyRetries(routes, scoped, mem)
scoped = withoutRetries(scoped)
if len(moved) == 0 && len(scoped) == 0 {
return false
@@ -949,8 +932,20 @@ func restartSeed(ctx context.Context, reg *registry.Registry, store release.Stor
return
}
_, readErrs := restartGenPass(ctx, routes, store, mem.gen)
- _, scopeErrs := restartScopePass(ctx, routes, store, mem.scope)
+ // A first sight cycles nothing, but it hands over the retry grants in
+ // both logs: they are not seed-skipped (Astra plan 9), so a grant
+ // written before this agent started, and not yet applied, is applied
+ // now. A grant spent or made stale since clears nothing.
+ scoped, scopeErrs := restartScopePass(ctx, routes, store, mem.scope)
readErrs = append(readErrs, scopeErrs...)
+ grants, grantErrs := hostRetryPass(ctx, routes, store, mem)
+ if len(grantErrs) > 0 {
+ // Not a baseline hole: the host-retry log is no restart signal, and
+ // an app it could not be read for is read again on the next move.
+ log.Printf("agent: retry baseline: %d apps unreadable: %v", len(grantErrs), grantErrs[0])
+ }
+ applyRetries(routes, grants, mem)
+ applyRetries(routes, scoped, mem)
if len(readErrs) > 0 {
log.Printf("agent: restart baseline: %d apps unreadable: %v", len(readErrs), readErrs[0])
return
diff --git a/internal/host/restartscope.go b/internal/host/restartscope.go
index 919f539d5..4b15e5bd7 100644
--- a/internal/host/restartscope.go
+++ b/internal/host/restartscope.go
@@ -52,6 +52,10 @@ import (
type restartMemory struct {
gen map[[2]string]string // app -> restart-gen.json etag, or genAbsent
scope map[[2]string]string // app -> last applied request id, or release.RestartScopeNone
+ // retries is app -> the ids of the host-retry log's entries this
+ // process has handled (hostRetryPass). It is not a seed mark: an empty
+ // memory, at agent start, handles every entry in the log.
+ retries map[[2]string]map[string]bool
// retryHost clears one host's failure hold, for a RestartScopeRetry
// request whose root-signed grant verified: the grant's build, and the
@@ -69,7 +73,7 @@ type restartMemory struct {
}
func newRestartMemory() *restartMemory {
- return &restartMemory{gen: map[[2]string]string{}, scope: map[[2]string]string{}}
+ return &restartMemory{gen: map[[2]string]string{}, scope: map[[2]string]string{}, retries: map[[2]string]map[string]bool{}}
}
// scopedRestart is one app's outstanding narrowed work: the requests this box
@@ -94,6 +98,14 @@ type scopedRestart struct {
// so. Restart requests are point-in-time events: a dropped one is recovered by
// typing the verb again, while a replayed one cycles production for something
// somebody asked for last week.
+//
+// A retry an earlier console left in this log is the one exception to both
+// (Astra plan 9): it is not a restart, and a grant is not seed-skipped. On
+// a first sight and on a lost position the log's retry entries are
+// reported anyway, for the caller to verify and apply exactly as on a move
+// (retryRequests, applyRetries): a grant spent or made stale since is a
+// no-op under the failure book's generation rule, and an unsigned one is
+// dropped with a line. withoutRetries keeps them out of every restart.
func restartScopePass(ctx context.Context, routes []registry.Route, store release.Store, cache map[[2]string]string) (out []scopedRestart, readErrs []error) {
apps := restartApps(routes)
live := make(map[[2]string]bool, len(apps))
@@ -111,7 +123,8 @@ func restartScopePass(ctx context.Context, routes []registry.Route, store releas
newest := g.Newest()
prev, seen := cache[k]
if !seen {
- cache[k] = newest // first sight: record, ask for nothing
+ cache[k] = newest // first sight: record, cycle nothing
+ out = appendRetries(out, k, g.Requests)
continue
}
if prev == newest {
@@ -121,6 +134,7 @@ func restartScopePass(ctx context.Context, routes []registry.Route, store releas
cache[k] = newest
if !found {
log.Printf("agent: narrowed restarts for %s/%s: this box's position is no longer in the log — re-baselined, nothing cycled (run the verb again if something is still waiting)", k[0], k[1])
+ out = appendRetries(out, k, g.Requests)
continue
}
if len(pending) > 0 {
@@ -192,9 +206,10 @@ type retryRequest struct {
signed string
}
-// retryRequests is the host-retry half of the narrowed log: the entries,
-// oldest first, whose host is one of this box's routes in the requesting
-// app. A host on another box names nothing here. Entries are not merged by
+// retryRequests is the retry entries of work, from the host-retry log
+// (hostRetryPass) or a legacy retry in the narrowed log (restartScopePass):
+// the entries, oldest first, whose host is one of this box's routes in the
+// requesting app. A host on another box names nothing here. Entries are not merged by
// host: each carries its own grant, and the box verifies each (a forged
// entry must not hide a real one behind it). A grant spent by an earlier
// entry moves its restriction's epoch, so a second one for the same failure
@@ -217,6 +232,102 @@ func retryRequests(routes []registry.Route, work []scopedRestart) []retryRequest
return out
}
+// appendRetries adds app's retry entries among requests to out, as one
+// scopedRestart, when there are any.
+func appendRetries(out []scopedRestart, app [2]string, requests []release.RestartRequest) []scopedRestart {
+ var rs []release.RestartRequest
+ for _, req := range requests {
+ if req.Scope.Kind == release.RestartScopeRetry {
+ rs = append(rs, req)
+ }
+ }
+ if len(rs) == 0 {
+ return out
+ }
+ return append(out, scopedRestart{app: app, requests: rs})
+}
+
+// hostRetryPass reads each app's host-retry log (release.HostRetryKey) and
+// returns the entries this process has not handled yet, for retryRequests
+// and applyRetries. It rides the serial like restartScopePass, one GET per
+// app on a move, and none at all on a box with nothing that could clear a
+// hold (retryHost nil), which no grant could change while the agent runs.
+//
+// It is not seed-skipped (Astra plan 9): a memory with no entry for an app,
+// at agent start or for an app this box has just gained, hands over every
+// entry in the log, so a grant written before an agent restart is applied
+// after it. That is safe because a grant is checked, not trusted: one past
+// its window, unsigned, or forged is dropped with a line, and one spent or
+// made stale since is a no-op under the failure book's generation rule
+// (RetryHostAt). Each entry is handled once per process: cache is the ids
+// handled, rebuilt from the log on each read so it stays as small as the
+// log, and an unchanged log hands over nothing, so nothing is verified
+// twice. A read or parse error leaves the app's ids as they were.
+func hostRetryPass(ctx context.Context, routes []registry.Route, store release.Store, mem *restartMemory) (out []scopedRestart, readErrs []error) {
+ if mem.retryHost == nil {
+ return nil, nil
+ }
+ apps := restartApps(routes)
+ live := make(map[[2]string]bool, len(apps))
+ for _, k := range apps {
+ live[k] = true
+ g, _, err := release.LoadHostRetries(ctx, store, k[0], k[1])
+ if err != nil {
+ readErrs = append(readErrs, fmt.Errorf("%s/%s host retries: %w", k[0], k[1], err))
+ continue
+ }
+ done := mem.retries[k]
+ next := make(map[string]bool, len(g.Requests))
+ var pending []release.RestartRequest
+ for _, req := range g.Requests {
+ if !done[req.ID] && !next[req.ID] {
+ pending = append(pending, req)
+ }
+ next[req.ID] = true
+ }
+ mem.retries[k] = next
+ out = appendRetries(out, k, pending)
+ }
+ for k := range mem.retries {
+ if !live[k] {
+ delete(mem.retries, k)
+ }
+ }
+ return out, readErrs
+}
+
+// applyRetries verifies and applies each retry in work naming a host this
+// box serves (retryRequests), the agent's one path for a grant from either
+// log. A retry clears a hold only on the console's root-signed grant for
+// its entry (spec §11, Astra impl-6 finding 5): every box can write both
+// logs, so an unsigned or foreign entry is dropped here, with a line,
+// before anything is cleared. The grant's epoch is checked against the
+// book where the clear is (rollout.Manager.RetryHost, or
+// failureHolds.retryHost on a box that declined the flag but honours its
+// book).
+func applyRetries(routes []registry.Route, work []scopedRestart, mem *restartMemory) {
+ for _, rq := range retryRequests(routes, work) {
+ if mem.retryHost == nil {
+ log.Printf("agent: retry requested for %s, dropped: rollout manager not armed on this box, so nothing is held to clear", rq.host)
+ continue
+ }
+ g, why := verifyRetry(mem.pub, rq, time.Now())
+ if why != "" {
+ log.Printf("agent: retry requested for %s, dropped: %s", rq.host, why)
+ continue
+ }
+ if mem.retryHost(rq.host, g.Build, g.Book, g.Epoch) {
+ next := "its next attempt runs as a canary"
+ if armedRollout() == nil {
+ next = "it starts the build as today" // a declined box runs no canary
+ }
+ log.Printf("agent: retry requested for %s: its failure hold on %s is cleared and %s", rq.host, g.Build, next)
+ } else {
+ log.Printf("agent: retry for %s: nothing held to clear for %s at epoch %d of book %q (cleared already, a newer failure since the grant, or another book's grant)", rq.host, g.Build, g.Epoch, g.Book)
+ }
+ }
+}
+
// verifyRetry is the box's check of one retry entry (spec §11, Paul's
// ruling on plan 6 Q4): the grant must verify against the root key and
// name this entry's account, app and host, within its window
diff --git a/internal/host/restartscope_test.go b/internal/host/restartscope_test.go
index cecdb2a8d..3a5bcf780 100644
--- a/internal/host/restartscope_test.go
+++ b/internal/host/restartscope_test.go
@@ -635,13 +635,63 @@ func askRetry(t *testing.T, ctx context.Context, s release.Store, priv ed25519.P
return wire
}
+// askSignedRetry is a console's append of a signed grant to the host-retry
+// log (release.AppendHostRetry).
func askSignedRetry(t *testing.T, ctx context.Context, s release.Store, acct, app, host, wire string) {
t.Helper()
- if _, err := release.AppendRestartScope(ctx, s, acct, app, release.RestartScope{Kind: release.RestartScopeRetry, Value: host, Signed: wire}); err != nil {
+ if _, err := release.AppendHostRetry(ctx, s, acct, app, host, wire); err != nil {
t.Fatal(err)
}
}
+// rawRetry appends a retry entry to the log at key with no validation: an
+// unsigned one in the host-retry log (any box can write it), or a retry in
+// the restart-scope log as a console built from main before the host-retry
+// log wrote it (legacyRetry), signed or, from an older one, not.
+func rawRetry(t *testing.T, ctx context.Context, s release.Store, key, host, wire string) string {
+ t.Helper()
+ g := &release.ScopedRestartGeneration{Schema: 1}
+ raw, etag, err := s.Get(ctx, key)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ etag = ""
+ case err != nil:
+ t.Fatal(err)
+ default:
+ if err := json.Unmarshal(raw, g); err != nil {
+ t.Fatal(err)
+ }
+ }
+ id := fmt.Sprintf("raw-%d-%d", len(g.Requests), time.Now().UnixNano())
+ g.Requests = append(g.Requests, release.RestartRequest{ID: id, RequestedAt: time.Now().UTC().Format(time.RFC3339),
+ Scope: release.RestartScope{Kind: release.RestartScopeRetry, Value: host, Signed: wire}})
+ body, err := json.Marshal(g)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(ctx, key, body, etag); err != nil {
+ t.Fatal(err)
+ }
+ return id
+}
+
+// legacyRetry is a retry in the restart-scope log, as a console built from
+// main before the host-retry log appended it.
+func legacyRetry(t *testing.T, ctx context.Context, s release.Store, acct, app, host, wire string) {
+ t.Helper()
+ rawRetry(t, ctx, s, release.RestartScopeKey(acct, app), host, wire)
+}
+
+// legacySignedRetry signs a grant as legacyRetry's console did.
+func legacySignedRetry(t *testing.T, ctx context.Context, s release.Store, priv ed25519.PrivateKey, acct, app, host, build, book string, epoch uint64) {
+ t.Helper()
+ wire, err := release.SignHostRetry(release.RootSigner(priv), acct, app, host, build, book, epoch, time.Now())
+ if err != nil {
+ t.Fatal(err)
+ }
+ legacyRetry(t, ctx, s, acct, app, host, wire)
+}
+
// Spec §11, Astra impl-6 finding 5: every box can write the restart-scope
// log, so only a grant the root signed for this entry's account, app and
// host reaches the manager. An unsigned entry (another box's, or an older
@@ -661,7 +711,7 @@ func TestRestartLegDropsARetryThatIsNotTheRootsGrant(t *testing.T) {
bounce := func(h string) bool { t.Errorf("%s was bounced", h); return true }
restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) // baseline
- askScoped(t, ctx, s, "bab", "eleven", release.RestartScopeRetry, "a.example.com")
+ rawRetry(t, ctx, s, release.HostRetryKey("bab", "eleven"), "a.example.com", "")
askRetry(t, ctx, s, other, "bab", "eleven", "a.example.com", "v2", "bk", 5)
forB, _ := release.SignHostRetry(release.RootSigner(priv), "bab", "eleven", "b.example.com", "v2", "bk", 5, time.Now())
askSignedRetry(t, ctx, s, "bab", "eleven", "a.example.com", forB)
@@ -767,7 +817,10 @@ func TestRestartLegRetryGrantsAgainstTheBook(t *testing.T) {
}
// With no manager armed (flag off, or a box that declined or refused it)
-// nothing is held: the request is consumed, logged, and cycles nothing.
+// nothing is held: a retry an older console left in the restart-scope log
+// is consumed, logged, and cycles nothing. Such a box does not read the
+// host-retry log at all (hostRetryPass): nothing it holds could be
+// cleared.
func TestRestartLegDropsARetryWithNoManager(t *testing.T) {
ctx, s := genKit(t)
dd := t.TempDir()
@@ -775,7 +828,7 @@ func TestRestartLegDropsARetryWithNoManager(t *testing.T) {
mem := newRestartMemory()
bounce := func(h string) bool { t.Errorf("%s was bounced", h); return true }
restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem)
- askScoped(t, ctx, s, "bab", "eleven", release.RestartScopeRetry, "a.example.com")
+ legacyRetry(t, ctx, s, "bab", "eleven", "a.example.com", "")
var logbuf bytes.Buffer
log.SetOutput(&logbuf)
@@ -816,7 +869,7 @@ func TestScopedHostsAndRootPassStepOverARetry(t *testing.T) {
ctx, s := genKit(t)
marks := restartMarksPath(t.TempDir())
restartPass(ctx, routes, s, marks, (&recordingRestart{}).fn) // baseline
- askScoped(t, ctx, s, "bab", "titogo", release.RestartScopeRetry, "bill.example.com")
+ legacyRetry(t, ctx, s, "bab", "titogo", "bill.example.com", "")
rec := &recordingRestart{}
restartPass(ctx, routes, s, marks, rec.fn)
if len(rec.hosts) != 0 {
@@ -865,3 +918,100 @@ func TestRestartLegLogsARetryWithNothingHeld(t *testing.T) {
t.Fatalf("log = %q", got)
}
}
+
+// Plan 9 (Astra), a console built from main before the host-retry log: its
+// signed, book-bound grant in the restart-scope log is verified and applied
+// with the same checks as one in the host-retry log, and an unsigned or
+// foreign one there is dropped with a line and clears nothing. An unchanged
+// log is not handled twice.
+func TestRestartLegAppliesALegacySignedRetryWithTheSameChecks(t *testing.T) {
+ ctx, s := genKit(t)
+ dd := t.TempDir()
+ routes := []registry.Route{execRoute("a.example.com", "bab", "eleven"), execRoute("b.example.com", "bab", "eleven")}
+ mem, priv := retryMemory(t)
+ _, other, _ := ed25519.GenerateKey(nil)
+ var retried []string
+ mem.retryHost = func(h, b, bk string, e uint64) bool {
+ retried = append(retried, fmt.Sprintf("%s %s %s %d", h, b, bk, e))
+ return true
+ }
+ bounce := func(h string) bool { t.Errorf("%s was bounced for a retry", h); return true }
+ restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem) // baseline
+
+ legacySignedRetry(t, ctx, s, priv, "bab", "eleven", "b.example.com", "v2", "bk", 5)
+ legacyRetry(t, ctx, s, "bab", "eleven", "a.example.com", "")
+ legacySignedRetry(t, ctx, s, other, "bab", "eleven", "a.example.com", "v2", "bk", 5)
+ var logbuf bytes.Buffer
+ log.SetOutput(&logbuf)
+ defer log.SetOutput(os.Stderr)
+ if asked := restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem); asked {
+ t.Fatal("a retry-only move asked for a root restart pass")
+ }
+ if !slices.Equal(retried, []string{"b.example.com v2 bk 5"}) {
+ t.Fatalf("retried %v, want the legacy signed grant for b.example.com only", retried)
+ }
+ got := logbuf.String()
+ if strings.Count(got, "agent: retry requested for a.example.com, dropped:") != 2 || !strings.Contains(got, "unsigned") {
+ t.Fatalf("log = %q, want the unsigned and the foreign legacy entries dropped", got)
+ }
+
+ logbuf.Reset()
+ restartRequestLeg(ctx, routes, s, dd, bounce, nil, mem)
+ if len(retried) != 1 || strings.Contains(logbuf.String(), "retry") {
+ t.Fatalf("an unchanged log was handled again: retried %v, log %q", retried, logbuf.String())
+ }
+}
+
+// Plan 9 (Astra), the ruling that grants are not seed-skipped: a grant
+// written before an agent restart, and not yet applied, is applied after
+// it, whichever log it rides, because the restart memory is rebuilt and a
+// spent or stale grant is a no-op under the generation rule. A restart
+// request written before the restart keeps today's seed-skip: it cycles
+// nothing. The grants are handled once.
+func TestARetryGrantWrittenBeforeAnAgentRestartIsApplied(t *testing.T) {
+ ctx, s := genKit(t)
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reg.Close()
+ routes := []registry.Route{execRoute("a.example.com", "bab", "eleven"), execRoute("b.example.com", "bab", "eleven")}
+ for _, rt := range routes {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ dd := t.TempDir()
+ first, priv := retryMemory(t)
+ first.retryHost = func(h, b, _ string, e uint64) bool {
+ t.Errorf("the first agent applied %s %s %d", h, b, e)
+ return true
+ }
+ restartSeed(ctx, reg, s, dd, first)
+
+ // The grants and a restart land; the agent restarts before its next
+ // serial move.
+ askRetry(t, ctx, s, priv, "bab", "eleven", "a.example.com", "v2", "bk", 5)
+ legacySignedRetry(t, ctx, s, priv, "bab", "eleven", "b.example.com", "v3", "bk", 6)
+ askScoped(t, ctx, s, "bab", "eleven", release.RestartScopeHost, "a.example.com")
+
+ second := newRestartMemory()
+ second.pub = first.pub
+ var retried []string
+ second.retryHost = func(h, b, bk string, e uint64) bool {
+ retried = append(retried, fmt.Sprintf("%s %s %s %d", h, b, bk, e))
+ return true
+ }
+ restartSeed(ctx, reg, s, dd, second)
+ slices.Sort(retried)
+ if !slices.Equal(retried, []string{"a.example.com v2 bk 5", "b.example.com v3 bk 6"}) {
+ t.Fatalf("after the restart retried %v, want both grants written before it", retried)
+ }
+ bounce := func(h string) bool { t.Errorf("%s was bounced: restarted before the agent", h); return true }
+ if asked := restartRequestLeg(ctx, routes, s, dd, bounce, nil, second); asked {
+ t.Fatal("the first move after the restart asked for a root pass")
+ }
+ if len(retried) != 2 {
+ t.Fatalf("the grants were handled again: %v", retried)
+ }
+}
diff --git a/internal/release/hostretry.go b/internal/release/hostretry.go
index 2aba99e9b..905c836b6 100644
--- a/internal/release/hostretry.go
+++ b/internal/release/hostretry.go
@@ -11,9 +11,10 @@ import (
// a box clears a host's failure hold only on a grant the deployment root
// signed, naming that account, app, host and the build that restricts it.
//
-// The restart-scope log it rides is writable by every box (Astra impl-6
-// finding 5), so an unsigned entry is a request anyone with a box could
-// have written, and it clears nothing. The signature closes forgery; Epoch
+// The host-retry log it rides (HostRetryKey; the restart-scope log before
+// readiness round 2, whose legacy entries boxes still honour) is writable
+// by every box (Astra impl-6 finding 5), so an unsigned entry is a request
+// anyone with a box could have written, and it clears nothing. The signature closes forgery; Epoch
// and Book close replay (plan review finding 13; the crash-epoch hard limit,
// readiness round 2 Task 1): Epoch is the app's restriction generation as
// the box published it (rollout.FailureBook.PublishedEpoch), which the box