carlos / platform Public

agent: the activator's start sequence is a named, tested function, so a claim box never backfills and a legacy box backfills between recovery and the pass's publication (task 6 fix round 1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev f9372fd5f2011243519dff0e4009a56da50dec03 parent 2cd8d79
4 files changed, +122 −38
  • internal/host/agent.go +26 −36
  • internal/host/instancekeys.go +2 −2
  • internal/host/instancekeys_test.go +51 −0
  • internal/host/layoutleg.go +43 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 3f6626576..9ab848b86 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -462,42 +462,32 @@ func runAgent(args []string, buildClaims ClaimFactory) {
// channel that never answers logs and moves on, and the box is no
// worse off than before this existed (a leftover unit that keeps
// running is exactly what an agent restart left before this pass).
- if claims.Writes.Enabled {
- svc.Start()
- } else {
- //
- // Then layout recovery (design §8.2, "Recovery is the journal"):
- // every journal a previous agent left is completed, and the host
- // converged to the fleet's intent, before Start can wake anything.
- // The pass is published only after that, so the serial leg never
- // runs beside recovery.
- startActivator(context.Background(),
- func(ctx context.Context) error {
- sw, err := eb.Control.Sweep(ctx)
- if err != nil {
- log.Printf("agent: instance sweep: %v (nested wakes refuse until the channel answers)", err)
- } else if len(sw.Units) > 0 {
- log.Printf("agent: swept %d instance unit(s) left from a previous agent: %v", len(sw.Units), sw.Units)
- }
- return err
- },
- func(ctx context.Context, swept bool) {
- pass := newLayoutPass(reg, svc, dd, eb.NestedOK, swept)
- intent := startupRecoveryIntent(ctx, dd, openDeployStore, metricsBoxLabel(os.Getenv, readMachineID()))
- recoverLayoutAtStart(ctx, pass, intent)
- // L21: every instance already on this box gets its key
- // after layout recovery has settled every journal and
- // before the layout pass is published or the activator
- // wakes anything, so nothing moves a file under it.
- if routes, err := reg.List(); err != nil {
- log.Printf("agent: instance keys: list routes: %v (each host's next start mints its key)", err)
- } else {
- backfillInstanceKeys(routes, eb)
- }
- armedLayoutPass.Store(pass)
- },
- svc.Start)
- }
+ //
+ // Then, on a legacy box, layout recovery and the instance-key
+ // backfill, before Start can wake anything; on a claim box, Start
+ // alone. The order and the branch are activatorStart's.
+ activatorStart{
+ Claims: claims.Writes.Enabled,
+ Sweep: func(ctx context.Context) error {
+ sw, err := eb.Control.Sweep(ctx)
+ if err != nil {
+ log.Printf("agent: instance sweep: %v (nested wakes refuse until the channel answers)", err)
+ } else if len(sw.Units) > 0 {
+ log.Printf("agent: swept %d instance unit(s) left from a previous agent: %v", len(sw.Units), sw.Units)
+ }
+ return err
+ },
+ Recover: func(ctx context.Context, swept bool) *layout.Pass {
+ pass := newLayoutPass(reg, svc, dd, eb.NestedOK, swept)
+ intent := startupRecoveryIntent(ctx, dd, openDeployStore, metricsBoxLabel(os.Getenv, readMachineID()))
+ recoverLayoutAtStart(ctx, pass, intent)
+ return pass
+ },
+ Routes: reg.List,
+ Keys: eb,
+ Publish: armedLayoutPass.Store,
+ Start: svc.Start,
+ }.Run(context.Background())
act = svc
log.Printf("agent: hibernation activator armed (bucket %s, idle %s)", cfg.Bucket, cfg.Idle)
diff --git a/internal/host/instancekeys.go b/internal/host/instancekeys.go
index 44fb2311b..e68775afa 100644
--- a/internal/host/instancekeys.go
+++ b/internal/host/instancekeys.go
@@ -60,7 +60,7 @@ func backfillInstanceKeys(routes []registry.Route, b instanceKeyBackfiller) inst
got, err := b.BackfillInstanceKey(rt)
if err != nil {
st.Failed++
- log.Printf("agent: instance key for %s: %v (its next start mints it)", rt.Host, err)
+ log.Printf("agent: instance key for %s: %v (its next start mints it, or refuses for the same reason)", rt.Host, err)
continue
}
switch got {
@@ -78,7 +78,7 @@ func backfillInstanceKeys(routes []registry.Route, b instanceKeyBackfiller) inst
st.Skipped++
}
}
- log.Printf("agent: instance keys: %d minted, %d already present, %d set by app configuration, %d never started here, %d failed, %d rows not instances",
+ log.Printf("agent: instance keys: %d minted, %d already present, %d set by app configuration, %d never started here, %d failed, %d rows skipped",
st.Minted, st.Present, st.FromConfig, st.NoEnvFile, st.Failed, st.Skipped)
return st
}
diff --git a/internal/host/instancekeys_test.go b/internal/host/instancekeys_test.go
index e06dfafea..950dd7ccc 100644
--- a/internal/host/instancekeys_test.go
+++ b/internal/host/instancekeys_test.go
@@ -1,10 +1,13 @@
package host
import (
+ "context"
"errors"
+ "reflect"
"testing"
"github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/layout"
"github.com/carlosframework/platform/internal/registry"
)
@@ -78,3 +81,51 @@ func TestBackfillInstanceKeysCountsAHostNeverStartedHere(t *testing.T) {
t.Fatalf("stats = %+v", st)
}
}
+
+// keyBackfillFunc is a backfiller made of one function.
+type keyBackfillFunc func(registry.Route) (activator.InstanceKeyOutcome, error)
+
+func (f keyBackfillFunc) BackfillInstanceKey(rt registry.Route) (activator.InstanceKeyOutcome, error) {
+ return f(rt)
+}
+
+// Task 6 ruling (K1): the start sequence is activatorStart's, so the two
+// guards runAgent cannot be tested for are pinned here. A claim box starts
+// and nothing else: no sweep, no recovery, no backfill (ruling 7). A legacy
+// box sweeps, recovers, backfills, publishes the pass and then starts, in
+// that order: the backfill after every journal is settled and before the
+// pass is published or anything wakes. A route list that fails skips the
+// backfill only; the pass is still published and the service still starts.
+func TestActivatorStartBackfillsOnlyALegacyBoxBetweenRecoveryAndPublish(t *testing.T) {
+ ready := registry.Route{Host: "shop.example.com", Kind: "instance", Status: "ready", Socket: "/run/carlos/shop.example.com.sock"}
+ for _, tc := range []struct {
+ name string
+ claims bool
+ listErr error
+ want []string
+ }{
+ {"legacy box", false, nil, []string{"sweep", "recover", "backfill", "publish", "start"}},
+ {"legacy box, routes unreadable", false, errors.New("db locked"), []string{"sweep", "recover", "publish", "start"}},
+ {"claim box", true, nil, []string{"start"}},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ var events []string
+ note := func(e string) { events = append(events, e) }
+ activatorStart{
+ Claims: tc.claims,
+ Sweep: func(context.Context) error { note("sweep"); return nil },
+ Recover: func(context.Context, bool) *layout.Pass { note("recover"); return nil },
+ Routes: func() ([]registry.Route, error) { return []registry.Route{ready}, tc.listErr },
+ Keys: keyBackfillFunc(func(registry.Route) (activator.InstanceKeyOutcome, error) {
+ note("backfill")
+ return activator.InstanceKeyMinted, nil
+ }),
+ Publish: func(*layout.Pass) { note("publish") },
+ Start: func() { note("start") },
+ }.Run(context.Background())
+ if !reflect.DeepEqual(events, tc.want) {
+ t.Fatalf("events = %v, want %v", events, tc.want)
+ }
+ })
+ }
+}
diff --git a/internal/host/layoutleg.go b/internal/host/layoutleg.go
index a035f412b..c3b7821aa 100644
--- a/internal/host/layoutleg.go
+++ b/internal/host/layoutleg.go
@@ -343,6 +343,49 @@ func startActivator(ctx context.Context, sweep func(context.Context) error, reco
start()
}
+// activatorStart is the activator's start sequence, assembled in runAgent
+// and run once by Run.
+//
+// On a claim box it is Start alone: every write to a host's installation
+// runs under the claim's guard, and the claim's launch source mints the
+// instance key there before every cold admission (claim_snapshot.go
+// EnsureClaimAdminToken), so a claim box never backfills (instance-key plan
+// ruling 7).
+//
+// On a legacy box it is the sweep, then layout recovery (design §8.2,
+// "Recovery is the journal": every journal a previous agent left is
+// completed and the host converged to the fleet's intent), then the
+// instance-key backfill (L21), then the pass's publication, then Start. The
+// backfill runs after recovery has settled every journal and before the
+// pass is published or the activator wakes anything, so nothing moves a
+// file under it; the pass is published only after recovery, so the serial
+// leg never runs beside it.
+type activatorStart struct {
+ Claims bool // claims.Writes.Enabled: a claim box
+ Sweep func(context.Context) error // the instance-unit sweep
+ Recover func(context.Context, bool) *layout.Pass // builds the pass and recovers under it; the bool is whether the sweep succeeded
+ Routes func() ([]registry.Route, error) // the registry's List
+ Keys instanceKeyBackfiller // the exec backend
+ Publish func(*layout.Pass) // armedLayoutPass.Store
+ Start func() // the service's Start
+}
+
+func (s activatorStart) Run(ctx context.Context) {
+ if s.Claims {
+ s.Start()
+ return
+ }
+ startActivator(ctx, s.Sweep, func(ctx context.Context, swept bool) {
+ pass := s.Recover(ctx, swept)
+ if routes, err := s.Routes(); err != nil {
+ log.Printf("agent: instance keys: list routes: %v (each host's next start mints its key)", err)
+ } else {
+ backfillInstanceKeys(routes, s.Keys)
+ }
+ s.Publish(pass)
+ }, s.Start)
+}
+
// layoutIntentTimeout bounds the one intent read at startup, which the
// activator's start waits on.
const layoutIntentTimeout = 30 * time.Second