carlos / platform Public

host: fastConvergeLoop — serial poll + outstanding-work sweep replaces canary-only fast adopt (#101)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 99ddfdedc52d79a5882298a3327f8d3a92074169 parent e676e72
6 files changed, +555 −87
  • internal/host/agent.go +293 −67
  • internal/host/agent_test.go +235 −0
  • internal/host/units/carlos-stage.path +1 −1
  • internal/release/adopt.go +14 −13
  • internal/release/adopt_test.go +4 −4
  • internal/release/materialize.go +8 −2
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 31927673a..96c1881ae 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -1,6 +1,6 @@
// The `carlos agent` verb — the single-box role: edge (TLS + host routing) +
-// hibernation activator + hourly release adoption (plus a canary/*-only
-// fast-adopt sweep every few seconds, canaryAdoptLoop) + the fleet status
+// hibernation activator + hourly release adoption (plus a 2s fast-converge
+// loop, fastConvergeLoop: serial poll + canary/unstaged sweep) + the fleet status
// page's 5-minute self-report composed in one process. The composition is
// new here, but each part is a port: the edge from Eleven's router.go, the
// activator from lchat activator.go, the adopt loop is release.Pass (or,
@@ -158,7 +158,7 @@ func RunAgent(args []string) {
}
go adoptLoop(reg, svc)
- go canaryAdoptLoop(reg, svc)
+ go fastConvergeLoop(reg, svc)
go statusLoop(reg)
go domainsLoop(reg, peekAcmeCache(args))
go instancesLoop(reg)
@@ -218,6 +218,14 @@ func peekRegistryPath(args []string, dd string) string {
// Store and key are resolved ONCE, at agent start, exactly like adoptLoop's:
// a wake is on a request's critical path, and re-reading env plus re-opening
// an S3 client per cold wake would put that work there for nothing.
+//
+// OnStageBlocked is wired so a cold wake on a hardened box (this sandboxed
+// process cannot write /opt/carlos) relays the stage it cannot perform to
+// the root pass (carlos-stage.path) instead of just failing the wake. NO
+// RestartHibernating here, deliberately: the host is cold, restart stays
+// nil — the activator exec'ing the freshly-pointed binary IS the restart.
+// The requester is built once with the hook, so its once-a-minute budget is
+// the hook's own, same shape as the loops'.
func materializeHook(reg *registry.Registry, dd string) func(context.Context, string) error {
store, err := openDeployStore(context.Background())
if err != nil {
@@ -230,8 +238,10 @@ func materializeHook(reg *registry.Registry, dd string) func(context.Context, st
return nil
}
optDir := release.OptDir()
+ stageReq := stageRequester(dd)
return func(ctx context.Context, host string) error {
- return release.MaterializeHost(ctx, reg, store, pub, optDir, dd, host)
+ return release.MaterializeHost(ctx, reg, store, pub, optDir, dd, host,
+ release.OnStageBlocked(stageReq))
}
}
@@ -521,7 +531,7 @@ const stageRequestEvery = time.Minute
// stageRequester returns a release.OnStageBlocked callback that touches the
// marker at most once per stageRequestEvery. The closure holds the clock, so
-// each loop gets its own budget: the canary loop asking does not silence the
+// each loop gets its own budget: the fast loop asking does not silence the
// hourly one, and neither can starve the other.
//
// Failures are logged and swallowed, like requestRootSync's: a request that
@@ -547,7 +557,7 @@ func stageRequester(dd string) func(host, ver string, err error) {
}
}
-// configGenLoop is canaryAdoptLoop's shape applied to issue #44: a config
+// configGenLoop is the fast-converge shape applied to issue #44: a config
// set should converge like a canary adopts — seconds, not the next hour.
// Every ~3s it reads one small object per config-carrying app (the
// generation object every config write touches) and, on movement, delivers
@@ -568,10 +578,11 @@ func stageRequester(dd string) func(host, ver string, err error) {
// /etc/carlos/host.env, so the case is theoretical — and the hourly timer
// floor stands regardless.
//
-// Cardinality: unlike canaryAdoptLoop, which is bounded to the deliberately
-// rare canary/* routes, this watches EVERY config-carrying app on the box.
-// That is accepted knowingly — one GET of the smallest object in the bucket
-// per app per 3s, against the store canaryAdoptLoop already hits every 2s. What
+// Cardinality: unlike fastConvergeLoop's sweep, which is bounded to the
+// deliberately rare canary/* routes plus still-unstaged ones, this watches
+// EVERY config-carrying app on the box. That is accepted knowingly — one GET
+// of the smallest object in the bucket
+// per app per 3s, against the store fastConvergeLoop already hits every 2s. What
// keeps it bounded is that the pass reads generation objects only: no
// manifests, no pointers, no per-environment resolution. All of that
// happens inside the SyncExec a real change triggers.
@@ -660,22 +671,239 @@ func configGenLoop(reg *registry.Registry, svc *activator.Service, dd string) {
}
}
-// canaryAdoptLoop mirrors adoptLoop but sweeps only routes following a
-// canary/* channel, every few seconds instead of hourly — "I promoted a
-// canary, why is my box still on the old version for the next 40 minutes"
-// with a real fix instead of an operator forcing `carlos adopt` by hand.
-// Bounded deliberately to canary/*: those channels are individually
-// created and inherently low-cardinality (one deliberate rollout at a
-// time, never the whole fleet — see release.PassFilter's doc comment and
-// bake.go's "canary/* has no window"), so this can't grow into a
-// fleet-wide poll storm the way sweeping every route this fast would.
-// Stable/beta routes stay on adoptLoop's hourly cadence: they're
-// bake-gated for a day or three regardless, so faster polling would buy
-// nothing there.
-// Takes the activator for the same reason adoptLoop does: a promoted canary
-// on a warm exec instance must bounce onto its new binary, not wait for
-// hibernation. Nil is fine.
-func canaryAdoptLoop(reg *registry.Registry, svc *activator.Service) {
+// Fast-converge cadence and clamps (carlos-deploy spec §3-§4). The tick is
+// the serial poll's latency promise (~2s from console bump to pass); the
+// clamp bounds what a burst of console writes can cost (one full pass set
+// per 5s, with the last bump deferred, never dropped); the backoff bounds
+// what a permanently-unstageable route can cost (one look per 5min at the
+// cap, instead of one per tick forever).
+const (
+ fastTickEvery = 2 * time.Second
+ fastFullClamp = 5 * time.Second
+ fastBackoffBase = 2 * time.Second
+ fastBackoffCap = 5 * time.Minute
+)
+
+// fastDeps is one fastConvergeLoop's state and seams — a struct so the tick
+// is testable without goroutines, env vars, or sleeps.
+type fastDeps struct {
+ reg *registry.Registry
+ store release.Store
+ pub ed25519.PublicKey
+ optDir string
+ restart func(string) error
+ passOpts []release.PassOption // fastPassOpts — every sweep pass carries all of them
+ fullPass func(ctx context.Context) // reconcile → adopt → config sync, or a test hook
+ lastSerial int64
+ seeded bool
+ lastFull time.Time
+ notBefore map[string]time.Time
+ step map[string]time.Duration
+ serialBroken bool // last ReadSerial failed and was logged; reset on success
+}
+
+// fastPassOpts is the option set EVERY fast-converge pass carries — the
+// serial-triggered full release.Pass and the sweep's release.PassFilter
+// both, built in one place so neither call site can drift from the other.
+//
+// DO NOT add release.NagExpiredCanaries(...) to this set, ever. This
+// is THE loop release.NagExpiredCanaries' doc comment warns about by
+// name: every 2-3 seconds, canary/* always in the set. An expired canary sitting
+// still would nag on every single tick — a journal-flooding poll storm
+// out of what is supposed to be an hourly, easy-to-skim log line. The
+// nag belongs to RunAdopt (carlos-adopt.service) alone.
+//
+// DO NOT add release.WithCapabilities(...) to this set, ever. Same
+// sandbox as adoptLoop above (ProtectSystem=strict,
+// ReadWritePaths=/data /run/carlos), and this one ticks every 2-3
+// SECONDS: arming the converge here would write nothing and log a
+// permission denial forever. The accepted consequence, ruled on in
+// the 2026-08-03 review of the capability design: a freshly promoted
+// canary runs its new binary against the unit shape the previous
+// version left until the hourly root pass converges it — up to an
+// hour. release.adoptHost prints one line per such repoint so the
+// window is visible in the journal rather than silent.
+//
+// OnStageBlocked IS wired here, and this is the loop it matters most
+// for: a canary is promoted to be picked up in seconds, and staging is
+// the one step of that pickup this process cannot perform. Throttled
+// inside stageRequester — this tick is 2-3 seconds, the request is at
+// most one a minute (the requester is built ONCE per loop, so the budget
+// is the loop's, not one tick's).
+//
+// RestartHibernating IS wired here too, and for the same reason as on
+// adoptLoop: this process owns the exec children. It matters more on
+// this loop than on any other — a promotion is bumped or fast-swept
+// precisely to be picked up in seconds, and the routes this loop reaches
+// are the warm ones somebody is watching. Pinned by
+// TestFastSweepBouncesAWarmHibernatingCanary: dropping it would restore
+// a dead-branch production bug without failing any other test.
+func fastPassOpts(dd string, stageReq func(host, ver string, err error), restart func(string) error) []release.PassOption {
+ return []release.PassOption{
+ release.BakeDir(dd),
+ release.OnStageBlocked(stageReq),
+ release.RestartHibernating(restart),
+ }
+}
+
+// fastSweepable mirrors release's adoptable predicate (adopt.go): the routes
+// a pass would consider at all. Anything else — platform aliases, non-ready
+// routes, routes with no app — must not count as outstanding work, or a
+// route the pass refuses on shape would make every tick sweep forever.
+func fastSweepable(rt registry.Route) bool {
+ return rt.Kind == "instance" && rt.App != "" && !rt.Platform &&
+ (rt.Status == "" || rt.Status == "ready")
+}
+
+// fastMembers computes the fast set for one tick — the routes with a reason
+// to be looked at FASTER than the hourly floor:
+//
+// - canary/* routes, always: a direct-bucket operator promote
+// (scripts/promote-app.sh, or `carlos promote` against the bucket)
+// never bumps the serial, so the sweep is the only thing keeping the
+// "canary adopts in seconds" promise (spec §4 correction).
+// - unstaged routes (no live symlink), once their backoff window has
+// passed: a route was just minted, or its stage failed; the sweep
+// picks it up as soon as there is something to stage.
+//
+// Computed BEFORE any store work so an empty set costs zero store reads —
+// the idle box pays one serial GET per tick and nothing else. A registry
+// list failure returns the empty set (a broken box, logged per tick by
+// statusLoop's identical call — not worth a second line every 2s here).
+func (d *fastDeps) fastMembers(now time.Time) []string {
+ routes, err := d.reg.List()
+ if err != nil {
+ return nil
+ }
+ var members []string
+ seen := map[string]bool{}
+ for _, rt := range routes {
+ if !fastSweepable(rt) || seen[rt.Host] {
+ continue
+ }
+ if isCanaryRoute(rt) {
+ seen[rt.Host] = true
+ members = append(members, rt.Host)
+ continue
+ }
+ // A LiveVersion error reads as unstaged: the pass is the thing that
+ // can repair a broken link, and the backoff bounds the retry cost.
+ if v, err := release.LiveVersion(d.optDir, rt.Host); err == nil && v != "" {
+ continue // staged: nothing outstanding at this cadence
+ }
+ if nb, ok := d.notBefore[rt.Host]; ok && now.Before(nb) {
+ continue // still inside this host's backoff window
+ }
+ seen[rt.Host] = true
+ members = append(members, rt.Host)
+ }
+ return members
+}
+
+// noteUnstaged records that host came through a sweep still unstaged:
+// double its backoff (base 2s, capped at 5min) and push its next look out.
+// Held routes cost almost nothing this way: the pass's own bake logic
+// refuses them and the backoff keeps re-checks rare; the serial-triggered
+// and hourly passes re-notice them when the window expires.
+func (d *fastDeps) noteUnstaged(host string, now time.Time) {
+ if d.step == nil {
+ d.step = map[string]time.Duration{}
+ d.notBefore = map[string]time.Time{}
+ }
+ s := d.step[host]
+ if s == 0 {
+ s = fastBackoffBase
+ } else if s *= 2; s > fastBackoffCap {
+ s = fastBackoffCap
+ }
+ d.step[host] = s
+ d.notBefore[host] = now.Add(s)
+}
+
+// backoffFor reports host's current backoff step (zero when none recorded).
+func (d *fastDeps) backoffFor(host string) time.Duration { return d.step[host] }
+
+// fastTick is one fast-converge tick — two independent jobs (spec §3-§4):
+//
+// 1. Serial check: one ReadSerial. The FIRST successful sight of the
+// object only seeds lastSerial — boot must not replay history
+// (adoptLoop's 1-minute first pass covers boot). Afterward any CHANGE
+// (not just increments — a reset must not wedge the box) runs the full
+// pass set, clamped to one per fastFullClamp: a bump inside the window
+// is deferred by NOT recording the acted serial, so the next tick past
+// the window re-fires. Read errors are tolerated silently (one log
+// line per state change, never per tick).
+// 2. Fast sweep: fastMembers, and only when non-empty one PassFilter over
+// exactly those hosts — an empty sweep costs zero store reads. After
+// the sweep, members still unstaged back off; staged members clear
+// their backoff state.
+func fastTick(ctx context.Context, d *fastDeps, now time.Time) {
+ doc, etag, err := release.ReadSerial(ctx, d.store)
+ switch {
+ case err != nil:
+ if !d.serialBroken {
+ log.Printf("agent: fast converge serial read: %v", err)
+ d.serialBroken = true
+ }
+ case etag == "":
+ // No serial object yet (pre-first-bump): nothing to seed — seeding
+ // the zero doc here would make the first real bump replay as a
+ // "change" that predates this box's interest.
+ d.serialBroken = false
+ case !d.seeded:
+ d.lastSerial, d.seeded = doc.Serial, true
+ d.serialBroken = false
+ default:
+ d.serialBroken = false
+ if doc.Serial != d.lastSerial && now.Sub(d.lastFull) >= fastFullClamp {
+ d.fullPass(ctx)
+ d.lastSerial = doc.Serial
+ d.lastFull = now
+ }
+ }
+
+ members := d.fastMembers(now)
+ if len(members) == 0 {
+ return // zero store reads: no PassFilter, no bake-clock write
+ }
+ member := map[string]bool{}
+ for _, h := range members {
+ member[h] = true
+ }
+ if err := release.PassFilter(ctx, d.reg, d.store, d.pub, d.optDir, d.restart,
+ func(rt registry.Route) bool { return member[rt.Host] }, d.passOpts...); err != nil {
+ log.Printf("agent: fast converge sweep: %v", err)
+ }
+ for _, h := range members {
+ if v, err := release.LiveVersion(d.optDir, h); err == nil && v != "" {
+ delete(d.notBefore, h)
+ delete(d.step, h)
+ } else {
+ d.noteUnstaged(h, now)
+ }
+ }
+}
+
+// fastConvergeLoop replaces the canary-only fast adopt loop with the
+// carlos-deploy fast set (spec §3-§4): a 2s serial poll that turns a console
+// bump (promote, instance create, config write) into the ordinary verified
+// pass set — reconcile → adopt → exec config sync — within seconds, plus a
+// fast sweep of the routes with genuinely outstanding work (canary/* always;
+// unstaged routes until they drain, with per-host backoff). Everything it
+// triggers is the same signature-verified, bake-gated work the hourly loops
+// run; the serial carries no authority, only earliness.
+//
+// Setup mirrors adoptLoop's: missing deployment config or pubkey disables
+// the loop silently (adoptLoop already logged the reason). The config-sync
+// leg degrades exactly as configSyncLoop does — no CARLOS_SECRETS_KEY, or a
+// broken one, and the full pass set simply omits that leg (configSyncLoop
+// already logged that too). Takes the activator for the same reason
+// adoptLoop does: a fresh version on a warm exec instance must bounce onto
+// its new binary, not wait for hibernation. Nil is fine.
+//
+// Thin and untested by convention, like adoptLoop/statusLoop — the tick's
+// whole decision lives in fastTick/fastMembers, which are tested.
+func fastConvergeLoop(reg *registry.Registry, svc *activator.Service) {
ctx := context.Background()
store, err := openDeployStore(ctx)
if err != nil {
@@ -685,45 +913,43 @@ func canaryAdoptLoop(reg *registry.Registry, svc *activator.Service) {
if err != nil {
return // ditto
}
+ var priv *ecdh.PrivateKey
+ if raw := os.Getenv("CARLOS_SECRETS_KEY"); raw != "" {
+ if p, err := appconfig.ParsePrivateKey(raw, "CARLOS_SECRETS_KEY"); err == nil {
+ priv = p
+ }
+ }
+ dd := dataDir()
optDir := release.OptDir()
restart := makeRestartRoute(reg, systemctlTryRestart, bounceFn(svc))
- log.Printf("agent: canary adopt loop armed (2s + jitter, canary/* routes only)")
+ // Built once, used by both pass shapes below — see fastPassOpts for the
+ // DO-NOT list and why the stage requester is per-loop, not per-tick.
+ opts := fastPassOpts(dd, stageRequester(dd), restart)
+ d := &fastDeps{
+ reg: reg, store: store, pub: pub, optDir: optDir,
+ restart: restart, passOpts: opts,
+ // The serial-triggered full pass set, in spec order: mint routes
+ // (reconcile), then stage them (adopt), then deliver their config.
+ // Each error is one log line, never fatal — the hourly floors stand
+ // underneath every leg.
+ fullPass: func(ctx context.Context) {
+ if err := provision.Reconcile(ctx, store, reg, provisionTickConfig(os.Getenv)); err != nil {
+ log.Printf("agent: fast converge reconcile: %v", err)
+ }
+ if err := release.Pass(ctx, reg, store, pub, optDir, restart, opts...); err != nil {
+ log.Printf("agent: fast converge adopt pass: %v", err)
+ }
+ if priv != nil {
+ if err := syncExecSerialized(ctx, reg, store, priv, dd, svc); err != nil {
+ log.Printf("agent: fast converge config sync: %v", err)
+ }
+ }
+ },
+ }
+ log.Printf("agent: fast converge loop armed (2s + jitter: serial poll + canary/* + unstaged routes)")
for {
- time.Sleep(2*time.Second + rand.N(time.Second))
- // DO NOT add release.NagExpiredCanaries(...) to this call, ever. This
- // is THE loop release.NagExpiredCanaries' doc comment warns about by
- // name: every 2-3 seconds, canary/* only. An expired canary sitting
- // still would nag on every single tick — a journal-flooding poll storm
- // out of what is supposed to be an hourly, easy-to-skim log line. The
- // nag belongs to RunAdopt (carlos-adopt.service) alone.
- //
- // DO NOT add release.WithCapabilities(...) to this call, ever. Same
- // sandbox as adoptLoop above (ProtectSystem=strict,
- // ReadWritePaths=/data /run/carlos), and this one ticks every 2-3
- // SECONDS: arming the converge here would write nothing and log a
- // permission denial forever. The accepted consequence, ruled on in
- // the 2026-08-03 review of the capability design: a freshly promoted
- // canary runs its new binary against the unit shape the previous
- // version left until the hourly root pass converges it — up to an
- // hour. release.adoptHost prints one line per such repoint so the
- // window is visible in the journal rather than silent.
- //
- // OnStageBlocked IS wired here, and this is the loop it matters most
- // for: a canary is promoted to be picked up in seconds, and staging is
- // the one step of that pickup this process cannot perform. Throttled
- // inside stageRequester — this tick is 2-3 seconds, the request is at
- // most one a minute.
- //
- // RestartHibernating IS wired here too, and for the same reason as on
- // adoptLoop: this process owns the exec children. It matters more on
- // this loop than on any other — a canary is promoted precisely to be
- // picked up in seconds, and the routes following canary/* are the warm
- // ones somebody is watching.
- if err := release.PassFilter(ctx, reg, store, pub, optDir, restart, isCanaryRoute,
- release.BakeDir(dataDir()), release.OnStageBlocked(stageRequester(dataDir())),
- release.RestartHibernating(restart)); err != nil {
- log.Printf("agent: canary adopt pass: %v", err)
- }
+ time.Sleep(fastTickEvery + rand.N(time.Second))
+ fastTick(ctx, d, time.Now())
}
}
@@ -1427,9 +1653,9 @@ func RunAdopt(args []string) {
// NagExpiredCanaries(os.Stdout) is set HERE ONLY (2026-08-03 review) — the
// canary-TTL nag (spec §5) belongs to the hourly root pass
// (carlos-adopt.service, this verb's ExecStart), the same split PR #9's
- // config sync above uses. adoptLoop and canaryAdoptLoop in this same
- // package call release.Pass/PassFilter without the nag option (they pass
- // BakeDir only), so an expired canary nags once an hour from here, never
+ // config sync above uses. adoptLoop and fastConvergeLoop in this same
+ // package call release.Pass/PassFilter without the nag option, so an
+ // expired canary nags once an hour from here, never
// every 2-3 seconds from the in-process fast loop.
//
// WithCapabilities(caps.Ops{}) is likewise set HERE ONLY, and the zero Ops
@@ -1437,8 +1663,8 @@ func RunAdopt(args []string) {
// useradd). This verb runs as root out of carlos-adopt.service; the
// in-process loops in this same file run inside carlos-edge.service's
// ProtectSystem=strict sandbox and cannot write unit configuration at all,
- // which is why they call Pass/PassFilter with BakeDir only and why both
- // carry a comment saying never to add this one.
+ // which is why adoptLoop and fastPassOpts each carry a comment saying
+ // never to add this one.
//
// -stage-only drops the nag and keeps everything else. It is what
// carlos-stage.service runs when the agent relays a version it could not
diff --git a/internal/host/agent_test.go b/internal/host/agent_test.go
index 6253653b9..aaa97093f 100644
--- a/internal/host/agent_test.go
+++ b/internal/host/agent_test.go
@@ -7,7 +7,10 @@ import (
"errors"
"path/filepath"
"runtime"
+ "slices"
+ "sort"
"testing"
+ "time"
"github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/registry"
@@ -652,3 +655,235 @@ func TestAdoptPassBouncesAWarmHibernatingInstance(t *testing.T) {
}
}
}
+
+// ---- fastConvergeLoop: serial poll + fast set (carlos-deploy spec §3-§4) ----
+
+// fastFixture is the shared shape of the fast-converge tick tests: a dir
+// store standing in for the deployment bucket, a bolt registry, a keypair,
+// and fresh opt/data dirs — internal/release's matFixture adapted to this
+// package's conventions (restartTestRegistry, real release helpers).
+type fastFixture struct {
+ store release.Store
+ pub ed25519.PublicKey
+ priv ed25519.PrivateKey
+ reg *registry.Registry
+ optDir string
+ dataDir string
+}
+
+func newFastFixture(t *testing.T) *fastFixture {
+ t.Helper()
+ pub, priv, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatalf("generate key: %v", err)
+ }
+ return &fastFixture{
+ store: release.NewDirStore(t.TempDir()),
+ pub: pub,
+ priv: priv,
+ reg: restartTestRegistry(t),
+ optDir: t.TempDir(),
+ dataDir: t.TempDir(),
+ }
+}
+
+// deps builds one tick's fastDeps with every seam pointed at the fixture.
+// fullPass, when non-nil, replaces the production reconcile→adopt→config-sync
+// set with a counter hook — the serial tests pin WHEN the set runs, not what
+// it does. The pass options come from fastPassOpts, the same builder
+// production uses, so a test exercising the sweep exercises the real option
+// set.
+func (f *fastFixture) deps(fullPass func()) *fastDeps {
+ restart := func(string) error { return nil }
+ d := &fastDeps{
+ reg: f.reg,
+ store: f.store,
+ pub: f.pub,
+ optDir: f.optDir,
+ restart: restart,
+ passOpts: fastPassOpts(f.dataDir, func(host, ver string, err error) {}, restart),
+ fullPass: func(context.Context) {},
+ }
+ if fullPass != nil {
+ d.fullPass = func(context.Context) { fullPass() }
+ }
+ return d
+}
+
+// addRoute mints an adoptable instance route on the given channel — the
+// shape release.Pass sweeps (kind instance, ready, non-platform).
+func (f *fastFixture) addRoute(t *testing.T, host, channel string) {
+ t.Helper()
+ if err := f.reg.Upsert(registry.Route{
+ Host: host, Kind: "instance", App: "fastapp", Channel: channel,
+ Status: "ready",
+ }); err != nil {
+ t.Fatalf("upsert %s: %v", host, err)
+ }
+}
+
+// stageLive stages ver and points host's live symlink at it, so
+// release.LiveVersion answers ver — "this host has nothing outstanding".
+func (f *fastFixture) stageLive(t *testing.T, host, ver string) {
+ t.Helper()
+ if err := release.StageArtifact(f.optDir, "bab", ver, "fastapp", []byte("staged build")); err != nil {
+ t.Fatalf("StageArtifact: %v", err)
+ }
+ if err := release.PointLive(f.optDir, host, "bab", ver, "fastapp"); err != nil {
+ t.Fatalf("PointLive: %v", err)
+ }
+}
+
+// TestFastTickActsOnSerialChangeNotBoot pins seed-don't-replay: the first
+// sight of the serial object only records it (boot must not replay history —
+// adoptLoop's 1-minute first pass covers boot), a subsequent CHANGE runs the
+// full pass set exactly once, and an unchanged serial runs nothing.
+func TestFastTickActsOnSerialChangeNotBoot(t *testing.T) {
+ env := newFastFixture(t)
+ var passes int
+ d := env.deps(func() { passes++ })
+ now := time.Unix(1754640000, 0)
+ fastTick(context.Background(), d, now) // no serial object at all: seeds nothing, runs nothing
+ if err := release.BumpSerial(context.Background(), env.store, now); err != nil {
+ t.Fatalf("BumpSerial: %v", err)
+ }
+ fastTick(context.Background(), d, now.Add(2*time.Second)) // first sight: SEED only
+ if passes != 0 {
+ t.Fatalf("boot seed must not run a pass, got %d", passes)
+ }
+ if err := release.BumpSerial(context.Background(), env.store, now); err != nil {
+ t.Fatalf("BumpSerial: %v", err)
+ }
+ fastTick(context.Background(), d, now.Add(4*time.Second))
+ if passes != 1 {
+ t.Fatalf("serial change must run the pass set once, got %d", passes)
+ }
+ fastTick(context.Background(), d, now.Add(6*time.Second))
+ if passes != 1 {
+ t.Fatalf("unchanged serial must not re-run, got %d", passes)
+ }
+}
+
+// TestFastTickClampsFullPasses pins the 5s clamp: a bump landing inside the
+// window is DEFERRED (the acted serial is not recorded), and the next tick
+// past the window fires it — a burst of console writes costs one pass set
+// per 5s, never one per write, and the last write is never lost.
+func TestFastTickClampsFullPasses(t *testing.T) {
+ env := newFastFixture(t)
+ var passes int
+ d := env.deps(func() { passes++ })
+ now := time.Unix(1754640000, 0)
+ if err := release.BumpSerial(context.Background(), env.store, now); err != nil {
+ t.Fatalf("BumpSerial: %v", err)
+ }
+ fastTick(context.Background(), d, now) // seed
+ if err := release.BumpSerial(context.Background(), env.store, now); err != nil {
+ t.Fatalf("BumpSerial: %v", err)
+ }
+ fastTick(context.Background(), d, now.Add(2*time.Second)) // pass 1
+ if err := release.BumpSerial(context.Background(), env.store, now); err != nil {
+ t.Fatalf("BumpSerial: %v", err)
+ }
+ fastTick(context.Background(), d, now.Add(4*time.Second)) // inside 5s clamp: deferred
+ if passes != 1 {
+ t.Fatalf("clamp: got %d passes", passes)
+ }
+ fastTick(context.Background(), d, now.Add(8*time.Second)) // clamp expired: fires
+ if passes != 2 {
+ t.Fatalf("deferred bump must fire after clamp, got %d", passes)
+ }
+}
+
+// TestFastSweepMembership pins who the fast sweep is FOR: canary/* routes
+// always (a direct-bucket operator promote never bumps the serial), unstaged
+// routes until they stage, staged non-canary routes never.
+func TestFastSweepMembership(t *testing.T) {
+ env := newFastFixture(t)
+ // three routes: a canary (always member), a staged edge route (not a
+ // member), an unstaged edge route (member until staged, then drops out).
+ env.addRoute(t, "canary.test", "canary/x")
+ env.addRoute(t, "staged.test", "edge")
+ env.stageLive(t, "staged.test", "abc1234") // mint its live symlink
+ env.addRoute(t, "cold.test", "edge")
+ members := env.deps(nil).fastMembers(time.Unix(1754640000, 0))
+ sort.Strings(members)
+ if want := []string{"canary.test", "cold.test"}; !slices.Equal(members, want) {
+ t.Fatalf("members = %v, want %v", members, want)
+ }
+}
+
+// TestFastSweepBackoffDoubles pins the per-host backoff that keeps a
+// permanently-unstageable route (nothing promoted, or a held head) from
+// costing store reads every 2s forever: 2s doubling to a 5-minute cap.
+func TestFastSweepBackoffDoubles(t *testing.T) {
+ env := newFastFixture(t)
+ env.addRoute(t, "broken.test", "edge") // nothing promoted: staging can never succeed
+ d := env.deps(nil)
+ now := time.Unix(1754640000, 0)
+ d.noteUnstaged("broken.test", now)
+ if m := d.fastMembers(now.Add(1 * time.Second)); len(m) != 0 {
+ t.Fatalf("inside backoff window, want no members, got %v", m)
+ }
+ if m := d.fastMembers(now.Add(3 * time.Second)); len(m) != 1 {
+ t.Fatalf("past first backoff, want member, got %v", m)
+ }
+ for i := 0; i < 20; i++ { // drive to the cap
+ d.noteUnstaged("broken.test", now)
+ }
+ if d.backoffFor("broken.test") != 5*time.Minute {
+ t.Fatalf("backoff cap = %v, want 5m", d.backoffFor("broken.test"))
+ }
+}
+
+// TestFastSweepBouncesAWarmHibernatingCanary pins, through the real option
+// builder (fastPassOpts) and the real closure (makeRestartRoute), that the
+// fast sweep's pass carries BOTH the positional restart and
+// release.RestartHibernating — dropping the option would silently restore
+// the dead-branch bug the option exists for (a warm exec hibernator keeps
+// serving its old binary until it happens to sleep) and no other test would
+// fail. warm.test (Hibernate: true) is only reachable via the option;
+// plain.test (Hibernate: false) is only reachable via the positional
+// restart; both must bounce.
+func TestFastSweepBouncesAWarmHibernatingCanary(t *testing.T) {
+ env := newFastFixture(t)
+ ctx := context.Background()
+ plat := runtime.GOOS + "-" + runtime.GOARCH
+
+ m := release.Manifest{
+ App: "fastapp", Version: "v1", Kind: "binary",
+ Artifacts: map[string]release.Artifact{plat: {}},
+ }
+ if err := release.Ship(ctx, env.store, "bab", m, map[string][]byte{plat: []byte("fast build")}); err != nil {
+ t.Fatalf("Ship: %v", err)
+ }
+ if err := release.Promote(ctx, env.store, env.priv, "bab", "fastapp", "canary/dev", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ if err := env.reg.Upsert(registry.Route{
+ Host: "warm.test", Kind: "instance", App: "fastapp", Channel: "canary/dev",
+ Status: "ready", Hibernate: true,
+ }); err != nil {
+ t.Fatalf("upsert warm.test: %v", err)
+ }
+ env.addRoute(t, "plain.test", "canary/dev")
+
+ var bounced []string
+ restart := makeRestartRoute(env.reg,
+ func(string) {},
+ func(h string) bool { bounced = append(bounced, h); return true })
+ d := env.deps(nil)
+ d.restart = restart
+ d.passOpts = fastPassOpts(env.dataDir, func(host, ver string, err error) {}, restart)
+
+ fastTick(ctx, d, time.Unix(1754640000, 0))
+
+ sort.Strings(bounced)
+ if want := []string{"plain.test", "warm.test"}; !slices.Equal(bounced, want) {
+ t.Fatalf("bounced = %v, want %v (warm.test missing = RestartHibernating dropped; plain.test missing = positional restart dropped)", bounced, want)
+ }
+ for _, h := range []string{"warm.test", "plain.test"} {
+ if v, err := release.LiveVersion(env.optDir, h); err != nil || v != "v1" {
+ t.Fatalf("%s live = %q, %v; want v1", h, v, err)
+ }
+ }
+}
diff --git a/internal/host/units/carlos-stage.path b/internal/host/units/carlos-stage.path
index f13cc0c19..009e4f8f6 100644
--- a/internal/host/units/carlos-stage.path
+++ b/internal/host/units/carlos-stage.path
@@ -1,5 +1,5 @@
# Fast release convergence — carlos-adopt.path's twin for the OTHER half the
-# sandboxed agent cannot do itself. The agent's adoptLoop/canaryAdoptLoop see a
+# sandboxed agent cannot do itself. The agent's adoptLoop/fastConvergeLoop see a
# promotion within seconds but run inside carlos-edge.service
# (ProtectSystem=strict, no /opt write), so they can resolve the new pointer
# and stage nothing; release.OnStageBlocked touches this file when that
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index 49993e1d4..7235d3d7a 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -68,14 +68,14 @@ type passConfig struct {
// review), and every future caller must keep it that way. The same split
// PR #9's config sync uses, for the same reason: the in-process adoptLoop
// (hourly + jitter, inside the sandboxed carlos-edge.service) and
-// canaryAdoptLoop (every 2-3 SECONDS, canary/* routes only — the whole
-// point of which is fast pickup of a freshly-promoted canary) must NEVER
+// fastConvergeLoop (every 2-3 SECONDS — the whole point of which is fast
+// pickup of a freshly-promoted canary or a serial bump) must NEVER
// set this. An expired canary sitting still on that fast loop would
// otherwise nag every 2-3 seconds forever, turning a visibility feature
// into a journal-flooding one; gating it to the hourly root pass keeps the
// nag journal-friendly — once an hour, not a poll-storm of log lines. This
// is enforced by convention (which call sites pass the option), not by the
-// type system — see agent.go's adoptLoop/canaryAdoptLoop for the loud
+// type system — see agent.go's adoptLoop/fastPassOpts for the loud
// "never add this" comments at the two places it would be easiest to add
// by accident.
func NagExpiredCanaries(w io.Writer) PassOption {
@@ -99,10 +99,10 @@ func NagExpiredCanaries(w io.Writer) PassOption {
// the only caller that sets it — for a harder reason than the nag's: this
// option WRITES /etc/systemd/system, and carlos-edge.service is
// ProtectSystem=strict with ReadWritePaths=/data /run/carlos, so the
-// in-process adoptLoop and canaryAdoptLoop physically cannot. Arming it
+// in-process adoptLoop and fastConvergeLoop physically cannot. Arming it
// there would not converge anything; it would log a permission denial every
// 2-3 seconds forever. Enforced by convention (which call sites pass the
-// option), not by the type system — see agent.go's adoptLoop/canaryAdoptLoop
+// option), not by the type system — see agent.go's adoptLoop/fastPassOpts
// for the loud "never add this" comments at the two places it would be
// easiest to add by accident.
//
@@ -180,7 +180,7 @@ func OnStageBlocked(fn func(host, ver string, err error)) PassOption {
// the activator's parent: the exec children belong to the agent, so its only
// available restart is systemctl, which cannot reach them — offering it
// sleepers would buy nothing and cost a try-restart per sleeping host per
-// version change. Set by the agent's in-process adoptLoop/canaryAdoptLoop
+// version change. Set by the agent's in-process adoptLoop/fastConvergeLoop
// alone, the two loops that run inside the process owning the children.
//
// Sleepers are still adopted AFTER every awake host and still poison a bad
@@ -233,12 +233,13 @@ func Pass(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.Publ
// (nil extra makes it identical to Pass) — the seam a faster, narrower
// sweep composes on top of the full one without duplicating the group
// resolution, bake-clock, staging, or restart logic. Built for the
-// canary-only fast-adopt loop (internal/host/agent.go): canary channels
-// are individually created and inherently low-cardinality (one deliberate
-// rollout at a time, never the whole fleet — see bake.go's "canary/* has
-// no window"), so sweeping just them every few seconds instead of hourly
-// can't turn into a fleet-wide poll storm the way sweeping every route
-// that fast would.
+// fast-converge sweep (internal/host/agent.go's fastConvergeLoop): canary
+// channels are individually created and inherently low-cardinality (one
+// deliberate rollout at a time, never the whole fleet — see bake.go's
+// "canary/* has no window"), and unstaged routes drain out of the set as
+// they stage (with per-host backoff while they can't), so sweeping just
+// those every few seconds instead of hourly can't turn into a fleet-wide
+// poll storm the way sweeping every route that fast would.
func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, optDir string, restart func(host string) error, extra func(registry.Route) bool, opts ...PassOption) error {
var cfg passConfig
for _, opt := range opts {
@@ -731,7 +732,7 @@ func adoptHost(ctx context.Context, reg *registry.Registry, s Store, optDir stri
// The accepted window, made visible (2026-08-03 ruling). A pass with no
// WithCapabilities — the in-process adoptLoop, and above all the 2-3
- // second canaryAdoptLoop, neither of which can write /etc from inside
+ // second fastConvergeLoop, neither of which can write /etc from inside
// carlos-edge.service — has just moved a unit-backed route onto a version
// that declares capabilities, without converging its unit shape.
//
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go
index 66e08cae4..9795b4a35 100644
--- a/internal/release/adopt_test.go
+++ b/internal/release/adopt_test.go
@@ -664,12 +664,12 @@ func TestPassAdoptsAnExpiredCanary(t *testing.T) {
}
// TestPassFilterDoesNotNagWithoutTheOption pins the fast-loop half of the
-// nag's scoping (2026-08-03 review): internal/host/agent.go's canaryAdoptLoop
-// calls PassFilter every 2-3 seconds for canary/* routes only, and NEVER
+// nag's scoping (2026-08-03 review): internal/host/agent.go's fastConvergeLoop
+// calls PassFilter every 2-3 seconds for its fast set, and NEVER
// passes NagExpiredCanaries — so an expired canary that is sitting still
// must produce ZERO nag lines from that path, not one every few seconds.
-// This is PassFilter called exactly the way canaryAdoptLoop calls it (no
-// options) against the same expired fixture TestPassAdoptsAnExpiredCanary
+// This is PassFilter called the way that loop calls it (without the nag
+// option) against the same expired fixture TestPassAdoptsAnExpiredCanary
// uses.
func TestPassFilterDoesNotNagWithoutTheOption(t *testing.T) {
s := NewDirStore(t.TempDir())
diff --git a/internal/release/materialize.go b/internal/release/materialize.go
index c50a2d80d..67fa70d2a 100644
--- a/internal/release/materialize.go
+++ b/internal/release/materialize.go
@@ -38,9 +38,15 @@ import (
// the window a cold wake observes is the same one the hourly root pass
// measures — a cold wake must never become a bake bypass by keeping its own
// clock.
-func MaterializeHost(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, optDir, dataDir, host string) error {
+//
+// opts are appended to the internal pass's options — the caller's chance to
+// carry OnStageBlocked, so a cold wake on a hardened box relays its blocked
+// stage to the root pass instead of just erroring (the agent's
+// materializeHook does exactly this). BakeDir is always set first and is not
+// the caller's to override.
+func MaterializeHost(ctx context.Context, reg *registry.Registry, s Store, pub ed25519.PublicKey, optDir, dataDir, host string, opts ...PassOption) error {
only := func(rt registry.Route) bool { return rt.Host == host }
- if err := PassFilter(ctx, reg, s, pub, optDir, nil, only, BakeDir(dataDir)); err != nil {
+ if err := PassFilter(ctx, reg, s, pub, optDir, nil, only, append([]PassOption{BakeDir(dataDir)}, opts...)...); err != nil {
return fmt.Errorf("materialize %s: %w", host, err)
}
link := LiveLink(optDir, host)