carlos / platform Public

plan 5 (scheduler) complete: 17 tasks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 024b629da7d5865164133da2f7653e816d01c4e4 parent 775d8f0
1 file changed, +1862 −7
  • docs/superpowers/plans/2026-10-07-zero-downtime-deploys-5-scheduler.md +1862 −7

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-5-scheduler.md b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-5-scheduler.md
index 6431eeade..29bded50c 100644
--- a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-5-scheduler.md
+++ b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-5-scheduler.md
@@ -73,7 +73,7 @@ From the index (exact names):
## Review Focus
-1. **Two releases promoted 20 s apart** (the index's cross-plan item, owned here). The second supersedes queued attempts for the first, per instance; a swap in flight onto the first finishes or is abandoned at its re-check and never switches to it. Pinned in Task 8 (`TestTwoReleasesTwentySecondsApartNeverSwitchToTheFirst`) and in Task 15's supersession invariant.
+1. **Two releases promoted 20 s apart** (the index's cross-plan item, owned here). The second supersedes queued attempts for the first, per instance; a swap in flight onto the first finishes or is abandoned at its re-check and never switches to it. Pinned in Task 8 (`TestTwoReleasesTwentySecondsApartNeverSwitchToTheFirst`) and in Task 16's supersession invariant (I4).
2. **A bucket blip while the app policy is read.** A policy read once keeps its last value; one never read gives `hold`, never `overlap`. Pinned in Task 3 (`TestPolicyCacheNeverGuessesOverlap`).
3. **An agent restart with a failure record on disk and the failed build still installed.** The next wake after the restart boots the good build. Pinned in Task 4 (`TestFailureBookSurvivesReopen`) and Task 10 (`TestPlanBootsTheGoodBuildForAHeldInstallation`).
4. **A version name re-shipped with different bytes.** The canonical digest check refuses the attempt as a staging error: no failure record, the stage relay touched, the instance left on its serving build. Pinned in Task 8 (`TestStagingRefusalWritesNoFailureRecord`).
@@ -95,7 +95,8 @@ From the index (exact names):
| `internal/rollout/scheduler.go` (create) | the per-app goroutine and `execute` |
| `internal/rollout/wakeplan.go` (create) | `decideWake`, `Manager.Plan`, `Manager.OnGen` |
| `internal/rollout/worker.go` (create) | socketless attempts, `CoalesceRecycle` |
-| `internal/rollout/*_test.go` (create) | tests per task, the fakes, the simulation |
+| `internal/rollout/status.go` (create) | `Manager.Status`, `reasonWord` |
+| `internal/rollout/*_test.go` (create) | tests per task, the fakes, the simulation (`sim_test.go`) |
| `internal/registry/registry.go` (modify) | four columns, `GoodBuild`, `SetGoodBuild`, `SetServingVersion` |
| `internal/appconfig/appconfig.go` (modify) | `Manifest.Swap`, `Manifest.SwapConcurrency`, validators |
| `internal/appconfig/claimsync.go`, `syncexec.go` (modify) | `Callbacks.Delivered` |
@@ -4085,6 +4086,7 @@ git commit -m "zdd-5 task 8: the manager and the per-app scheduler"
// Service gains: Planner func(ctx context.Context, rt registry.Route) WakePlan; OnGen func(GenEvent); OnDrift func(host string)
func (a *Service) Current(host string) (Gen, bool)
func (a *Service) BounceFor(host string, cause Cause) bool
+ func bounceText(worker bool, cause Cause) string // plan 6 rewords it
func (b *SplitBackend) KillGen(ctx context.Context, rt registry.Route, g Gen) error // only if plan 2 did not add it
// startPinned gains a last parameter: healthBy time.Time (zero: no cap)
var firstWakeBudget, firstWakeKillReserve, firstWakeKillBound, firstWakeSettle time.Duration // 55 s, 25 s, 2 s, 10 s
@@ -4604,13 +4606,20 @@ func (a *Service) BounceFor(host string, cause Cause) bool {
if e == nil || e.state != actAwake {
return false
}
+ emitEvent(a.Logs, e.route, "info", bounceText(e.worker, cause))
+ a.beginStopLocked(e)
+ return true
+}
+
+// bounceText is the one place BounceFor words its event. Draft wording:
+// plan 6 rewrites it through copy review. The flag-off literal in
+// bounceLocked is never edited.
+func bounceText(worker bool, cause Cause) string {
noun := "instance"
- if e.worker {
+ if worker {
noun = "sidecar"
}
- emitEvent(a.Logs, e.route, "info", noun+" restarting ("+string(cause)+")")
- a.beginStopLocked(e)
- return true
+ return noun + " restarting (" + string(cause) + ")"
}
// killFailed ends a failed first-wake build outright (spec §8.2 step 1). A
@@ -5382,4 +5391,1850 @@ git commit -m "zdd-5 task 10: the wake planner and planned-wake outcomes"
---
-<!-- CONTINUE -->
+### Task 11: Socketless attempts and the recycle hook
+
+**Files:**
+- Create: `internal/rollout/worker.go`, `internal/rollout/worker_test.go`
+- Modify: `internal/rollout/scheduler.go` (`execute`)
+
+**Interfaces:**
+- Consumes: Task 8's `Activator.BounceFor`, `Activator.WakeWorker`; Task 10's `workerWait`, `Manager.workers`, `OnGen`; Task 6's `gate`, `start`.
+- Produces:
+ ```go
+ const workerAttemptTimeout = 2 * time.Minute
+ func (s *scheduler) executeWorker(ctx context.Context, d dispatch, rt registry.Route) activator.SwapResult
+ func (q *appQueue) admitOne(host string, now time.Time) (dispatch, bool)
+ func (m *Manager) CoalesceRecycle(host string) bool // plan 4's Service.CoalesceRecycle
+ ```
+
+Spec §6.5 says a failed socketless `release`, `install` or `retry` attempt "is treated as a failed first wake". So a worker attempt is a stop (`BounceFor`) and a wake (`WakeWorker`), and the wake runs Task 9's planned path: Task 10's planner boots the attempt's build, the settle window is the readiness check, and an overlap failure starts the good build in its place. The stop releases and re-takes the lease, unlike §7.9's same-entry respawn (see the report).
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/rollout/worker_test.go`:
+
+```go
+package rollout
+
+import (
+ "testing"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// workerHarness turns the harness's hosts into always-on sidecars.
+func workerHarness(t *testing.T, hosts ...string) *harness {
+ h := newHarness(t, hosts...)
+ for _, host := range hosts {
+ r := h.rows.get(host)
+ r.Kind, r.Hibernate = "sidecar", false
+ h.rows.put(r)
+ }
+ return h
+}
+
+func TestAWorkerReleaseIsAStopAndAFirstWake(t *testing.T) {
+ h := workerHarness(t, "bot")
+ h.stage("v2")
+ h.act.wake = func(rt registry.Route) error {
+ h.act.serve(rt.Host, "v2")
+ g, _ := h.act.Current(rt.Host)
+ go func() {
+ h.m.OnGen(activator.GenEvent{Kind: activator.GenHealthy, Route: rt, Gen: g, FirstWake: true})
+ h.m.OnGen(activator.GenEvent{Kind: activator.GenSettled, Route: rt, Gen: g, FirstWake: true})
+ }()
+ return nil
+ }
+ h.install("v2", "bot")
+ h.pass()
+ waitFor(t, "the good build to move to v2", func() bool { return h.rows.get("bot").GoodVersion == "v2" })
+ calls, _, _ := h.act.snapshot()
+ if len(calls) != 0 {
+ t.Fatal("a socketless attempt went through Swap; it is stop-then-start (spec §7.9)")
+ }
+ h.act.mu.Lock()
+ defer h.act.mu.Unlock()
+ if len(h.act.bounced) != 1 || h.act.bounced[0] != "bot" {
+ t.Fatalf("bounced %v", h.act.bounced)
+ }
+}
+
+func TestAFailedWorkerReleaseFallsBackAndHalts(t *testing.T) {
+ h := workerHarness(t, "bot")
+ h.stage("v2")
+ h.act.wake = func(rt registry.Route) error {
+ h.act.serve(rt.Host, "v1")
+ g, _ := h.act.Current(rt.Host)
+ go h.m.OnGen(activator.GenEvent{Kind: activator.GenFellBack, Route: rt, Gen: g, FirstWake: true,
+ Mode: activator.ModeOverlap, Failed: release.StagedIdentity{Version: "v2"}, Reason: "exited within its settle window"})
+ return nil
+ }
+ h.install("v2", "bot")
+ h.pass()
+ waitFor(t, "a failure record", func() bool { _, ok := h.book.Lookup("bab", "blog", "v2"); return ok })
+ if r := h.rows.get("bot"); r.GoodVersion != "v1" {
+ t.Fatalf("good build = %q after a failed worker release", r.GoodVersion)
+ }
+}
+
+// Spec §7.9: a recycle that falls due coalesces with a queued release attempt
+// only when the scheduler would admit that attempt now.
+func TestARecycleCoalescesOnlyWithAnAdmissibleRelease(t *testing.T) {
+ q := awakeQueue(noBlocks, "a", "b")
+ releaseAll(q, "v2", "a", "b")
+ canary, ok := q.admitOne("a", t0)
+ if !ok || !canary.Canary {
+ t.Fatalf("admitOne(a) = %+v %v, want a's canary", canary, ok)
+ }
+ if _, ok := q.admitOne("b", t0); ok {
+ t.Fatal("coalesced past swap_concurrency 1 while another worker's canary runs")
+ }
+ q.finish(canary, activator.SwapDone)
+ if d, ok := q.admitOne("b", t0); !ok || d.ID.Build != "v2" {
+ t.Fatalf("admitOne(b) = %+v %v after the canary settled", d, ok)
+ }
+ r := awakeQueue(noBlocks, "c")
+ r.wantRestart("c", "r1", t0)
+ if _, ok := r.admitOne("c", t0); ok {
+ t.Fatal("a restart is not a release attempt; the recycle runs on the serving build")
+ }
+ h := newHarness(t, "a")
+ if h.m.CoalesceRecycle("nobody") {
+ t.Fatal("an unknown host coalesced")
+ }
+}
+```
+
+- [ ] **Step 2: Run them to see them fail**
+
+Run: `go test ./internal/rollout/ -run 'TestAWorkerRelease|TestAFailedWorkerRelease|TestARecycleCoalesces' -count=1`
+Expected: FAIL, `q.admitOne undefined`.
+
+- [ ] **Step 3: Write the worker path**
+
+Create `internal/rollout/worker.go`:
+
+```go
+package rollout
+
+import (
+ "context"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// workerAttemptTimeout bounds how long a socketless attempt waits for its
+// wake's outcome: the settle window, a fallback's settle and the wake's own
+// work fit inside it many times over.
+const workerAttemptTimeout = 2 * time.Minute
+
+// executeWorker runs one attempt on a socketless host (spec §6.5, §7.9): stop
+// it, then wake it. While the attempt is registered in m.workers, the
+// planner boots the attempt's build, and the wake's outcome comes back on the
+// channel. No outcome at all (the wake failed before anything executed: a
+// lease, a staging refusal) is not a build failure.
+func (s *scheduler) executeWorker(ctx context.Context, d dispatch, rt registry.Route) activator.SwapResult {
+ m := s.m
+ w := &workerWait{id: d.ID, ch: make(chan activator.GenEvent, 4)}
+ m.mu.Lock()
+ m.workers[rt.Host] = w
+ m.mu.Unlock()
+ defer func() {
+ m.mu.Lock()
+ if m.workers[rt.Host] == w {
+ delete(m.workers, rt.Host)
+ }
+ m.mu.Unlock()
+ }()
+ m.emit(rt, EvSwapping, "info", textSwapping(d.ID.Build, d.ID.Cause))
+ m.d.Act.BounceFor(rt.Host, d.ID.Cause)
+ werr := m.d.Act.WakeWorker(rt)
+ wait := workerAttemptTimeout
+ if werr != nil {
+ // A wake that failed reports its outcome, if any, right after it
+ // returns (Task 9's afterWake runs once e.ready has closed).
+ wait = time.Second
+ }
+ timer := time.NewTimer(wait)
+ defer timer.Stop()
+ for {
+ select {
+ case ev := <-w.ch:
+ switch ev.Kind {
+ case activator.GenHealthy:
+ if !ev.FirstWake {
+ return activator.SwapResult{Outcome: activator.SwapDone, Gen: ev.Gen}
+ }
+ case activator.GenSettled:
+ return activator.SwapResult{Outcome: activator.SwapDone, Gen: ev.Gen}
+ case activator.GenFellBack:
+ return activator.SwapResult{Outcome: activator.SwapFailed, Gen: ev.Gen, Reason: ev.Reason}
+ case activator.GenFailed:
+ return activator.SwapResult{Outcome: activator.SwapDown, Reason: ev.Reason}
+ }
+ case <-timer.C:
+ reason := "the worker's wake reported no outcome"
+ if werr != nil {
+ reason = werr.Error()
+ }
+ s.mu.Lock()
+ s.q.deferHost(rt.Host, m.d.Now().Add(stagingDeferral))
+ s.mu.Unlock()
+ return activator.SwapResult{Outcome: activator.SwapAbandoned, Reason: reason}
+ case <-ctx.Done():
+ return activator.SwapResult{Outcome: activator.SwapAbandoned, Reason: ctx.Err().Error()}
+ }
+ }
+}
+
+// admitOne is admit for one host, for the recycle hook: the host's pending
+// attempt, if it is a release-class attempt the queue would start right now.
+func (q *appQueue) admitOne(host string, now time.Time) (dispatch, bool) {
+ h := q.hosts[host]
+ if h == nil || h.running != nil || q.running >= q.concurrency {
+ return dispatch{}, false
+ }
+ d, ok := q.gate(h, now)
+ if !ok || d.Class != classAdopt {
+ return dispatch{}, false
+ }
+ q.start(h, d)
+ return d, true
+}
+
+// CoalesceRecycle is plan 4's Service.CoalesceRecycle (spec §7.9). It is
+// called under the activator's lock, so it calls nothing in the activator:
+// the attempt it admits runs on its own goroutine. True means the attempt
+// makes the spawn and the recycle is skipped.
+func (m *Manager) CoalesceRecycle(host string) bool {
+ s := m.schedulerOf(host)
+ if s == nil {
+ return false
+ }
+ s.mu.Lock()
+ d, ok := s.q.admitOne(host, m.d.Now())
+ s.mu.Unlock()
+ if !ok {
+ return false
+ }
+ go s.execute(m.ctx, d)
+ return true
+}
+```
+
+In `scheduler.go`'s `execute`, directly after the `rt, err := m.d.Routes.Get(...)` block, add:
+
+```go
+ if rt.IsSidecar() {
+ s.done(d, s.executeWorker(ctx, d, *rt), "")
+ return
+ }
+```
+
+- [ ] **Step 4: Run them to see them pass**
+
+Run: `go test ./internal/rollout/ -count=1 -race`
+Expected: `ok github.com/carlosframework/platform/internal/rollout`
+
+- [ ] **Step 5: Commit**
+
+```bash
+go vet ./internal/rollout/ && test -z "$(gofmt -l internal/rollout)"
+git add internal/rollout/worker.go internal/rollout/worker_test.go internal/rollout/scheduler.go
+git commit -m "zdd-5 task 11: socketless attempts and the recycle hook"
+```
+
+---
+
+### Task 12: The generations file, and the pins root reads
+
+**Files:**
+- Create: `internal/activator/genfile.go`, `internal/activator/genfile_test.go`
+- Modify: `internal/activator/activator.go` (`Service`, `Start`, end of `wake`, end of `finishStop` at 2355, end of `sweepOnce`), plan 3's `leaveSet` and `swapOverlap`, plan 4's recycle install in `respawnWorkerLocked`
+- Modify: `internal/release/pins.go` (plan 4's `BoxPins`), `internal/release/pins_test.go`
+- Modify: `internal/host/versionsweep.go`, `internal/host/versionsweep_test.go`
+
+**Interfaces:**
+- Consumes: plan 4's `release.Generations`, `GenerationsPath`, `WriteGenerations`, `ReadGenerations`, `GenerationPins`, `BoxPins`; plan 3's `e.ops.members`, `e.pin`; Task 9's `e.plan`.
+- Produces:
+ ```go
+ // Service gains: GenerationsFile string (empty: never written, the flag off)
+ func (a *Service) generationsLocked() release.Generations
+ func (a *Service) persistGenerations()
+ func sweepStagedVersionsWith(reg routeLister, optDir, dataDir string, grace time.Duration, now time.Time) // internal/host
+ ```
+
+Plan 4 already reads the file in every adopt pass with a data dir (`GenerationPins(GenerationsPath(cfg.bakeDir), …)`), so root's restage refusal needs nothing more from this plan than the file itself and `GoodVersion` in `BoxPins`. The hourly version sweep (`internal/host/versionsweep.go`) is separate and learns both here.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/activator/genfile_test.go`:
+
+```go
+package activator
+
+import (
+ "context"
+ "path/filepath"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestTheGenerationsFileFollowsEveryChange(t *testing.T) {
+ dir := t.TempDir()
+ good := stagedID(t, dir, "v1")
+ a, _, _ := planActivator(t, WakePlan{Boot: good, Mode: ModeOverlap})
+ a.GenerationsFile = release.GenerationsPath(dir)
+ a.persistGenerations()
+ g, err := release.ReadGenerations(a.GenerationsFile)
+ if err != nil || g == nil || len(g.Hosts) != 0 {
+ t.Fatalf("before any spawn: %+v, %v; want an empty file, written", g, err)
+ }
+ done, err := a.beginRequest(context.Background(), testRoute("one.example.com"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ done()
+ g, err = release.ReadGenerations(a.GenerationsFile)
+ if err != nil || !slices.Equal(g.Hosts["one.example.com"], []string{"v1"}) {
+ t.Fatalf("awake: %+v, %v", g, err)
+ }
+ a.BounceFor("one.example.com", CauseRestart)
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ if g, _ = release.ReadGenerations(a.GenerationsFile); g != nil && len(g.Hosts) == 0 {
+ return
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatalf("after the stop: %+v, want no generations", g)
+}
+
+func TestTheGenerationsFileNamesThePlannedBuildBeforeItSpawns(t *testing.T) {
+ dir := t.TempDir()
+ good, next := stagedID(t, dir, "v1"), stagedID(t, dir, "v2")
+ a, b, _ := planActivator(t, WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good})
+ a.GenerationsFile = filepath.Join(dir, "zdd", "generations.json")
+ b.gate = make(chan struct{})
+ go a.beginRequest(context.Background(), testRoute("one.example.com"))
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ g, _ := release.ReadGenerations(a.GenerationsFile)
+ if g != nil && slices.Equal(g.Hosts["one.example.com"], []string{"v1", "v2"}) {
+ close(b.gate)
+ return
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ close(b.gate)
+ t.Fatal("the spawning build and its fallback were not pinned before the spawn")
+}
+```
+
+Append to plan 4's `internal/release/pins_test.go`:
+
+```go
+func TestBoxPinsNamesTheGoodBuild(t *testing.T) {
+ opt := t.TempDir()
+ if err := StageArtifact(opt, "bab", "v0", "km", []byte("v0")); err != nil {
+ t.Fatal(err)
+ }
+ pins, err := BoxPins(opt, []registry.Route{{Host: "b.example.com", Kind: "instance", App: "km", Account: "bab", Version: "v2", GoodVersion: "v0"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !pins.Has(VersionPath(opt, "bab", "v0", "km")) {
+ t.Fatal("GoodVersion is a pin (spec §8.5)")
+ }
+}
+```
+
+Append to `internal/host/versionsweep_test.go`:
+
+```go
+func TestSweepKeepsGoodBuildsAndGenerations(t *testing.T) {
+ opt, dd := t.TempDir(), t.TempDir()
+ old := time.Now().Add(-48 * time.Hour)
+ for _, v := range []string{"good", "gen", "gone"} {
+ if err := release.StageArtifact(opt, "", v, "km", []byte(v)); err != nil {
+ t.Fatal(err)
+ }
+ _ = os.Chtimes(release.VersionPath(opt, "", v, "km"), old, old)
+ }
+ rows := fakeLister{routes: []registry.Route{{Host: "a.example.com", Kind: "instance", App: "km", Version: "x", GoodVersion: "good"}}}
+ if err := release.WriteGenerations(release.GenerationsPath(dd), release.Generations{Hosts: map[string][]string{"a.example.com": {"gen"}}}); err != nil {
+ t.Fatal(err)
+ }
+ sweepStagedVersionsWith(rows, opt, dd, 6*time.Hour, time.Now())
+ for v, want := range map[string]bool{"good": true, "gen": true, "gone": false} {
+ _, err := os.Stat(release.VersionPath(opt, "", v, "km"))
+ if (err == nil) != want {
+ t.Errorf("%s present = %v, want %v", v, err == nil, want)
+ }
+ }
+}
+
+func TestAnUnreadableGenerationsFileSkipsTheSweep(t *testing.T) {
+ opt, dd := t.TempDir(), t.TempDir()
+ old := time.Now().Add(-48 * time.Hour)
+ if err := release.StageArtifact(opt, "", "gone", "km", []byte("x")); err != nil {
+ t.Fatal(err)
+ }
+ _ = os.Chtimes(release.VersionPath(opt, "", "gone", "km"), old, old)
+ path := release.GenerationsPath(dd)
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte("{half"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ sweepStagedVersionsWith(fakeLister{}, opt, dd, 6*time.Hour, time.Now())
+ if _, err := os.Stat(release.VersionPath(opt, "", "gone", "km")); err != nil {
+ t.Fatal("swept with an incomplete pin set (index amendments)")
+ }
+}
+```
+
+`fakeLister` is the existing test double in `versionsweep_test.go`; if its field is not named `routes`, use its name. Add `os`, `path/filepath`, `registry` and `release` to the file's imports as needed.
+
+- [ ] **Step 2: Run them to see them fail**
+
+Run: `TMPDIR=/tmp/k go test ./internal/activator/ ./internal/release/ ./internal/host/ -run 'TestTheGenerationsFile|TestBoxPinsNamesTheGoodBuild|TestSweepKeeps|TestAnUnreadableGenerations' -count=1`
+Expected: FAIL, `a.GenerationsFile undefined`, `undefined: sweepStagedVersionsWith`, and the `BoxPins` assertion.
+
+- [ ] **Step 3: Write the generations writer**
+
+Create `internal/activator/genfile.go`:
+
+```go
+package activator
+
+import (
+ "log"
+ "slices"
+ "sync"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// genFileMu serialises writers of the generations file, each of which takes
+// its snapshot under it: a later snapshot can never be overwritten by an
+// earlier one. Never taken while holding a.mu.
+var genFileMu sync.Mutex
+
+// generationsLocked is every live or pending generation's build, per host
+// (spec §8.5: current, successor, retiring, spawning), in plan 4's format.
+// A wake's planned build and its fallback count from the moment they are
+// planned, so they are on disk before anything execs them. The caller holds
+// a.mu.
+func (a *Service) generationsLocked() release.Generations {
+ g := release.Generations{Schema: 1, Hosts: map[string][]string{}}
+ for host, e := range a.entries {
+ set := map[string]bool{e.gen.Build: true, e.workerServing.Version: true}
+ if e.pin != nil {
+ set[e.pin.Build] = true
+ }
+ if e.plan.planned() {
+ set[e.plan.Boot.Version] = true
+ if e.plan.Fallback != nil {
+ set[e.plan.Fallback.Version] = true
+ }
+ }
+ for _, m := range e.ops.members {
+ set[m.gen.Build] = true
+ }
+ delete(set, "")
+ if len(set) == 0 {
+ continue
+ }
+ vers := make([]string, 0, len(set))
+ for v := range set {
+ vers = append(vers, v)
+ }
+ slices.Sort(vers)
+ g.Hosts[host] = vers
+ }
+ return g
+}
+
+// persistGenerations rewrites the generations file (index amendments) with
+// plan 4's atomic writer. Called without a.mu, before the first spawn
+// (Start), before every planned spawn, and after every change to an
+// operation set. A failed write is logged: root then reads the previous
+// file, and the next change or sweep writes again.
+func (a *Service) persistGenerations() {
+ if a.GenerationsFile == "" {
+ return
+ }
+ genFileMu.Lock()
+ defer genFileMu.Unlock()
+ a.mu.Lock()
+ g := a.generationsLocked()
+ a.mu.Unlock()
+ if err := release.WriteGenerations(a.GenerationsFile, g); err != nil {
+ log.Printf("activator: write %s: %v", a.GenerationsFile, err)
+ }
+}
+```
+
+Add to `Service` in `activator.go`, beside `Planner`:
+
+```go
+ // GenerationsFile, when set, is where every live or pending generation's
+ // build is published for root's pin set (release.GenerationsPath). Set
+ // only with CARLOS_SWAP=overlap; empty writes nothing.
+ GenerationsFile string
+```
+
+Call `a.persistGenerations()` (never while holding `a.mu`) at each of these points:
+- `Start`: as its first statement, so the file exists before the first spawn.
+- `doWake`: directly after the planning block Task 9 added (after `a.mu.Unlock()` that stores `e.plan`), and before step 1. Add it after the `e.pin` hold path too: put the call outside the `if a.Planner != nil && e.pin == nil {` block so it runs for both.
+- `wake`: after the `a.afterWake(e, w, err)` line Task 9 added.
+- `finishStop`: after `close(e.stopped)`.
+- `sweepOnce`: as its last statement, after `a.mu.Unlock()`: one rewrite per sweep catches any change the points above miss.
+- plan 3's `swapOverlap`: directly before its `gb.StartGen(` call (the successor is already in the operation set), and plan 3's `leaveSet`: before `close(m.done)`.
+- plan 4's `respawnWorkerLocked` goroutine: after the `default:` arm installs the new child and unlocks (`a.mu.Unlock()` before its `log.Printf("activator: worker %s respawned …`).
+
+- [ ] **Step 4: Put the good build in the pin sets**
+
+In plan 4's `BoxPins` (`internal/release/pins.go`), change the per-row list from `[]string{rt.Version, rt.PrevVersion}` to `[]string{rt.Version, rt.PrevVersion, rt.GoodVersion}`, and add `GoodVersion` to the doc comment's list.
+
+In `internal/host/versionsweep.go`, rename `sweepStagedVersions` to `sweepStagedVersionsWith`, add a `dataDir string` parameter after `optDir`, and in its body add the good build to the per-row pins and the generations file to the set:
+
+```go
+ if r.GoodVersion != "" {
+ pinned[r.GoodVersion] = true
+ }
+```
+
+```go
+ // Live generations exist only in the agent's memory; it publishes them
+ // (zero-downtime plan 5). An unreadable file is an incomplete pin set,
+ // and an incomplete pin set deletes nothing (index amendments).
+ gens, err := release.ReadGenerations(release.GenerationsPath(dataDir))
+ if err != nil {
+ log.Printf("adopt: staged-version sweep skipped: %v", err)
+ return
+ }
+ if gens != nil {
+ for _, vers := range gens.Hosts {
+ for _, v := range vers {
+ pinned[v] = true
+ }
+ }
+ }
+```
+
+(the second block directly before `res, err := release.SweepStaged(...)`). Keep the old name as a wrapper, so existing tests compile unchanged:
+
+```go
+func sweepStagedVersions(reg routeLister, optDir string, grace time.Duration, now time.Time) {
+ sweepStagedVersionsWith(reg, optDir, "", grace, now)
+}
+```
+
+With an empty `dataDir`, `GenerationsPath("")` names `zdd/generations.json` relative to the working directory, which does not exist in a test: no generations. `sweepStagedVersionsPass` calls `sweepStagedVersionsWith(reg, release.OptDir(), dataDir(), versionSweepGrace(os.Getenv), time.Now())`.
+
+- [ ] **Step 5: Run the tests**
+
+Run: `TMPDIR=/tmp/k go test ./internal/activator/ ./internal/release/ ./internal/host/ -count=1`
+Expected: three `ok` lines.
+
+- [ ] **Step 6: Commit**
+
+```bash
+go build ./... && go vet ./internal/activator/ ./internal/release/ ./internal/host/ && test -z "$(gofmt -l internal)"
+git add internal/activator/ internal/release/pins.go internal/release/pins_test.go internal/host/versionsweep.go internal/host/versionsweep_test.go
+git commit -m "zdd-5 task 12: the generations file; GoodVersion and generations in root's pin sets"
+```
+
+---
+
+### Task 13: The box status report
+
+**Files:**
+- Modify: `internal/status/snapshot.go` (on `17362eaf`: `Probes` ends at line 168 after `ReleaseRefusals`; `BoxMetrics` near 419; `CollectBox`'s "something known" condition at 826 and its `BoxMetrics` literal near 860)
+- Test: `internal/status/snapshot_test.go` (append)
+- Create: `internal/rollout/status.go`, `internal/rollout/status_test.go`
+- Modify: `internal/rollout/manager.go` (`Deps.OnChange`, `Manager.observing`), `scheduler.go` (`done`), `wakeplan.go` (`OnGen`)
+
+**Interfaces:**
+- Produces (plan 6 reads these names; they match plan 6's assumed rows):
+ ```go
+ // status
+ type RolloutReport struct { Hosts map[string]HostRollout; Apps map[string]AppRollout; Failures []RolloutFailure }
+ type HostRollout struct { Account, App, Installed, Serving, Good string; Awake bool; Attempt, AttemptBuild, HeldBy, Reason string; ConfigPending bool; ConfigError string }
+ type AppRollout struct { Queued, Running, Settling int } // keyed "<acct>/<app>"
+ type RolloutFailure struct { Account, App, Build, Host, Mode, Reason string; PromotedAt, At time.Time; HeldDown []string }
+ const AttemptQueued, AttemptRunning, AttemptSettling, AttemptFailed, AttemptDown = "queued", "running", "settling", "failed", "down"
+ const ReasonHealth, ReasonSettle, ReasonExit, ReasonStaging, ReasonReused = "health", "settle", "exit", "staging", "reused"
+ // Probes.Rollout and BoxMetrics.Rollout *RolloutReport `json:"rollout,omitempty"`
+ // rollout
+ func (m *Manager) Status() status.RolloutReport
+ func reasonWord(text string) string
+ // Deps gains OnChange func(): called after every rollout transition
+ ```
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/status/snapshot_test.go`:
+
+```go
+func TestTheRolloutReportRidesThePrivateMetrics(t *testing.T) {
+ r := &RolloutReport{Hosts: map[string]HostRollout{"a.example.com": {App: "blog", Installed: "v2", Serving: "v1", Attempt: AttemptFailed, HeldBy: "v2", Reason: ReasonHealth}}}
+ box := CollectBox(nil, Probes{Rollout: r}, time.Now())
+ if box.Metrics == nil || box.Metrics.Rollout == nil || box.Metrics.Rollout.Hosts["a.example.com"].HeldBy != "v2" {
+ t.Fatalf("metrics = %+v", box.Metrics)
+ }
+ if b := CollectBox(nil, Probes{}, time.Now()); b.Metrics != nil {
+ t.Fatal("the flag off publishes no rollout block and no Metrics because of it")
+ }
+}
+```
+
+Create `internal/rollout/status_test.go`:
+
+```go
+package rollout
+
+import (
+ "testing"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+func TestStatusReportsHeldAndRollingInstances(t *testing.T) {
+ h := newHarness(t, "a", "b")
+ h.stage("v2")
+ gate := h.act.hold("a")
+ h.install("v2", "a", "b")
+ h.book.Record(FailureRecord{Account: "bab", App: "blog", Build: "v3", Host: "z", Reason: "health check failed", At: t0})
+ h.pass()
+ waitFor(t, "a's canary", func() bool { _, s, _ := h.act.snapshot(); return len(s) == 1 })
+ r := h.m.Status()
+ if got := r.Hosts["a"]; got.Attempt != status.AttemptSettling || got.AttemptBuild != "v2" || got.Installed != "v2" || got.Serving != "v1" {
+ t.Fatalf("a = %+v, want the canary settling onto v2", got)
+ }
+ if got := r.Hosts["b"]; got.Attempt != status.AttemptQueued {
+ t.Fatalf("b = %+v, want queued behind the canary", got)
+ }
+ if app := r.Apps["bab/blog"]; app.Settling != 1 || app.Queued != 1 {
+ t.Fatalf("app = %+v", app)
+ }
+ if len(r.Failures) != 1 || r.Failures[0].Build != "v3" {
+ t.Fatalf("failures = %+v", r.Failures)
+ }
+ close(gate)
+}
+
+func TestAFailedFirstWakeShowsAsHeld(t *testing.T) {
+ h := newHarness(t, "a")
+ h.stage("v2")
+ h.install("v2", "a")
+ h.pass()
+ h.m.OnGen(activator.GenEvent{Kind: activator.GenFellBack, Route: h.rows.get("a"), FirstWake: true,
+ Mode: activator.ModeOverlap, Gen: activator.Gen{Build: "v1", Digest: "sha-v1"}, Failed: release.StagedIdentity{Version: "v2"},
+ Reason: "exited within 10s of becoming healthy"})
+ got := h.m.Status().Hosts["a"]
+ if got.Attempt != status.AttemptFailed || got.AttemptBuild != "v2" || got.HeldBy != "v2" || got.Reason != status.ReasonExit {
+ t.Fatalf("a = %+v", got)
+ }
+}
+
+func TestReasonWords(t *testing.T) {
+ for text, want := range map[string]string{
+ "health check failed": status.ReasonHealth,
+ "swap: successor exited during its settle": status.ReasonSettle,
+ "exited within 10s of becoming healthy": status.ReasonExit,
+ "staging: digest mismatch": status.ReasonStaging,
+ "version name reused with different content, ship under a new name": status.ReasonReused,
+ "GET / answered 500": status.ReasonHealth,
+ } {
+ if got := reasonWord(text); got != want {
+ t.Errorf("reasonWord(%q) = %q, want %q", text, got, want)
+ }
+ }
+}
+```
+
+Add `"github.com/carlosframework/platform/internal/release"` to that file's imports.
+
+- [ ] **Step 2: Run them to see them fail**
+
+Run: `go test ./internal/status/ ./internal/rollout/ -run 'TestTheRolloutReport|TestStatusReports|TestAFailedFirstWakeShows|TestReasonWords' -count=1`
+Expected: FAIL, `undefined: RolloutReport`.
+
+- [ ] **Step 3: Add the report to status**
+
+In `internal/status/snapshot.go`, add after the `ReleaseRefusals` type (or anywhere at top level):
+
+```go
+// RolloutReport is this box's rollout scheduler state (zero-downtime spec
+// §8.4, §9.1), published only with CARLOS_SWAP=overlap. It names hosts, so
+// it lives in the private Metrics block alone.
+type RolloutReport struct {
+ Hosts map[string]HostRollout `json:"hosts,omitempty"`
+ Apps map[string]AppRollout `json:"apps,omitempty"` // keyed "<acct>/<app>"
+ Failures []RolloutFailure `json:"failures,omitempty"`
+}
+
+// HostRollout is one managed instance. Attempt and Reason are the closed
+// words below; plan 6 renders them.
+type HostRollout struct {
+ Account string `json:"account"`
+ App string `json:"app"`
+ Installed string `json:"installed"`
+ Serving string `json:"serving,omitempty"`
+ Good string `json:"good,omitempty"`
+ Awake bool `json:"awake"`
+ Attempt string `json:"attempt,omitempty"`
+ AttemptBuild string `json:"attempt_build,omitempty"`
+ HeldBy string `json:"held_by,omitempty"`
+ Reason string `json:"reason,omitempty"`
+ ConfigPending bool `json:"config_pending,omitempty"`
+ ConfigError string `json:"config_error,omitempty"`
+}
+
+// AppRollout counts one app's attempts. Settling is canary settles and
+// first-wake observations under way.
+type AppRollout struct {
+ Queued int `json:"queued"`
+ Running int `json:"running"`
+ Settling int `json:"settling"`
+}
+
+// RolloutFailure is one failure record (spec §8.4).
+type RolloutFailure struct {
+ Account string `json:"account"`
+ App string `json:"app"`
+ Build string `json:"build"`
+ Host string `json:"host"`
+ Mode string `json:"mode,omitempty"`
+ Reason string `json:"reason"`
+ PromotedAt time.Time `json:"promoted_at"`
+ At time.Time `json:"at"`
+ HeldDown []string `json:"held_down,omitempty"`
+}
+
+// The attempt words.
+const (
+ AttemptQueued = "queued"
+ AttemptRunning = "running"
+ AttemptSettling = "settling"
+ AttemptFailed = "failed"
+ AttemptDown = "down"
+)
+
+// The reason words.
+const (
+ ReasonHealth = "health"
+ ReasonSettle = "settle"
+ ReasonExit = "exit"
+ ReasonStaging = "staging"
+ ReasonReused = "reused"
+)
+```
+
+Add `Rollout *RolloutReport` to `Probes` (after `ReleaseRefusals`) and `Rollout *RolloutReport \`json:"rollout,omitempty"\`` to `BoxMetrics` (after its `ReleaseRefusals`). In `CollectBox`, add `|| p.Rollout != nil` to the "something known" condition, and `Rollout: p.Rollout,` to the `BoxMetrics` literal.
+
+- [ ] **Step 4: Write the manager's report**
+
+In `manager.go`, add to `Deps`:
+
+```go
+ // OnChange, when set, is told after every rollout transition, so the box
+ // republishes its status within seconds (plan 6's deploy watch).
+ OnChange func()
+```
+
+add `observing map[string]bool // first wakes under their 10 s observation` to `Manager`, `observing: map[string]bool{}` to `NewManager`'s literal, and this method:
+
+```go
+func (m *Manager) changed() {
+ if m.d.OnChange != nil {
+ m.d.OnChange()
+ }
+}
+```
+
+Call `m.changed()` as the last statement of `scheduler.done` (before the deferred `s.poke()` runs, so add it as a `defer m.changed()` directly after `defer s.poke()`) and of `Manager.OnGen`. In `OnGen`, add first:
+
+```go
+ m.mu.Lock()
+ switch {
+ case ev.Kind == activator.GenHealthy && ev.FirstWake:
+ m.observing[rt.Host] = true
+ case ev.Kind != activator.GenHealthy:
+ delete(m.observing, rt.Host)
+ }
+ m.mu.Unlock()
+```
+
+Create `internal/rollout/status.go`:
+
+```go
+package rollout
+
+import (
+ "strings"
+
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// reasonWord turns an attempt's diagnostic text into one of status's closed
+// reason words, so no free text reaches a member-facing surface. Anything
+// unrecognised is a failed health check: the swap's checks are health, the
+// settle and the exit watch.
+func reasonWord(text string) string {
+ t := strings.ToLower(text)
+ switch {
+ case strings.Contains(t, "reused"):
+ return status.ReasonReused
+ case strings.Contains(t, "staging"), strings.Contains(t, "staged"), strings.Contains(t, "digest"):
+ return status.ReasonStaging
+ case strings.Contains(t, "settle"):
+ return status.ReasonSettle
+ case strings.Contains(t, "exited"):
+ return status.ReasonExit
+ }
+ return status.ReasonHealth
+}
+
+// Status is the box's rollout report (spec §8.4, §9.1).
+func (m *Manager) Status() status.RolloutReport {
+ r := status.RolloutReport{Hosts: map[string]status.HostRollout{}, Apps: map[string]status.AppRollout{}}
+ for _, f := range m.d.Book.List() {
+ r.Failures = append(r.Failures, status.RolloutFailure{Account: f.Account, App: f.App, Build: f.Build, Host: f.Host,
+ Mode: f.Mode, Reason: reasonWord(f.Reason), PromotedAt: f.PromotedAt, At: f.At, HeldDown: f.HeldDown})
+ }
+ m.mu.Lock()
+ scheds := make([]*scheduler, 0, len(m.apps))
+ for _, s := range m.apps {
+ scheds = append(scheds, s)
+ }
+ observing := map[string]bool{}
+ for h := range m.observing {
+ observing[h] = true
+ }
+ cfg := map[string]configTrack{}
+ for h, t := range m.cfg {
+ cfg[h] = *t
+ }
+ m.mu.Unlock()
+ for _, s := range scheds {
+ app := status.AppRollout{}
+ s.mu.Lock()
+ for host, h := range s.q.hosts {
+ hr := status.HostRollout{Account: s.key.acct, App: s.key.app, Installed: h.wantBuild, Serving: h.serving,
+ Good: h.good, Awake: h.serving != ""}
+ if t, ok := cfg[host]; ok && t.delivered != "" && t.delivered != t.active {
+ hr.ConfigPending, hr.ConfigError = true, t.lastErr
+ }
+ switch {
+ case h.running != nil && (h.running.Canary || observing[host]):
+ hr.Attempt, hr.AttemptBuild = status.AttemptSettling, h.running.ID.Build
+ app.Settling++
+ case h.running != nil:
+ hr.Attempt, hr.AttemptBuild = status.AttemptRunning, h.running.ID.Build
+ app.Running++
+ case observing[host]:
+ hr.Attempt, hr.AttemptBuild = status.AttemptSettling, h.wantBuild
+ app.Settling++
+ default:
+ if d, ok := s.q.pending(h); ok {
+ hr.Attempt, hr.AttemptBuild = status.AttemptQueued, d.ID.Build
+ app.Queued++
+ }
+ }
+ if rec, ok := m.d.Book.Lookup(s.key.acct, s.key.app, h.wantBuild); ok && hr.Attempt == "" &&
+ m.d.Book.Blocks(s.key.acct, s.key.app, h.wantBuild, host, h.wantBuild == h.good) {
+ hr.Attempt, hr.AttemptBuild, hr.HeldBy, hr.Reason = status.AttemptFailed, rec.Build, rec.Build, reasonWord(rec.Reason)
+ if m.d.Book.HeldDown(s.key.acct, s.key.app, h.wantBuild, host) {
+ hr.Attempt = status.AttemptDown
+ }
+ }
+ r.Hosts[host] = hr
+ }
+ s.mu.Unlock()
+ r.Apps[s.key.acct+"/"+s.key.app] = app
+ }
+ return r
+}
+```
+
+- [ ] **Step 5: Run the tests**
+
+Run: `go test ./internal/status/ ./internal/rollout/ -count=1 -race`
+Expected: two `ok` lines.
+
+- [ ] **Step 6: Commit**
+
+```bash
+go build ./... && go vet ./internal/status/ ./internal/rollout/ && test -z "$(gofmt -l internal/status internal/rollout)"
+git add internal/status/snapshot.go internal/status/snapshot_test.go internal/rollout/
+git commit -m "zdd-5 task 13: the rollout report in box status"
+```
+
+---
+
+### Task 14: Wire `CARLOS_SWAP=overlap` into the agent
+
+**Files:**
+- Create: `internal/host/rollout.go`, `internal/host/rollout_test.go`
+- Modify: `internal/host/agent.go` (on `17362eaf`: `svc = activator.New(` at 378, `svc.Logs = logSink` at 393, `syncExecSerialized` at 1055, `convergeLegFor` call at 2205, `restartRequestLeg(ctx, routes` at 2265, `statusLoop` at 3057 and its `time.Sleep(delay)`, the probe line `p.ReleaseRefusals = releaseRefusalProbe()` at 3237, `adoptBounceFn` at 3665)
+- Modify: `internal/host/restart.go` (`restartRequestLeg` at 860)
+
+**Interfaces:**
+- Consumes: everything above; plan 1's `activator.SetGenerations`; plan 3's `Config.Swap`; `release.Channel`, `release.CanonicalDemand`, `appconfig.Get`, `appconfig.Callbacks.Delivered`.
+- Produces:
+ ```go
+ func armedRollout() *rollout.Manager // nil unless CARLOS_SWAP=overlap; plan 6 calls it in fastConvergeLoop
+ func armRollout(ctx context.Context, getenv func(string) string, svc *activator.Service, reg *registry.Registry, dd string, sink activator.EventSink, openStore func(context.Context) (release.Store, error)) *rollout.Manager
+ func rolloutLegFor(m *rollout.Manager, reg *registry.Registry) func()
+ func feedRestarts(routes []registry.Route, moved [][2]string, scoped []scopedRestart, gen map[[2]string]string, feed func(host, id string) bool) int
+ func rolloutStatusProbe() *status.RolloutReport
+ var statusNudge chan struct{} // cap 1
+ ```
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/host/rollout_test.go`:
+
+```go
+package host
+
+import (
+ "context"
+ "errors"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func rolloutFixture(t *testing.T) (*activator.Service, *registry.Registry, string) {
+ t.Helper()
+ dd := t.TempDir()
+ reg, err := registry.Open(filepath.Join(dd, "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ svc := activator.New(activator.Config{Bucket: "b", Idle: time.Hour}, &activator.SplitBackend{}, reg)
+ return svc, reg, dd
+}
+
+// Cross-plan review focus 1: the flag off changes nothing.
+func TestTheFlagOffArmsNothing(t *testing.T) {
+ svc, reg, dd := rolloutFixture(t)
+ opened := false
+ m := armRollout(context.Background(), func(string) string { return "" }, svc, reg, dd, nil,
+ func(context.Context) (release.Store, error) { opened = true; return nil, errors.New("unused") })
+ if m != nil || armedRollout() != nil || opened {
+ t.Fatal("CARLOS_SWAP unset armed the scheduler")
+ }
+ if svc.Planner != nil || svc.OnGen != nil || svc.OnDrift != nil || svc.CoalesceRecycle != nil || svc.GenerationsFile != "" {
+ t.Fatal("CARLOS_SWAP unset set an activator hook")
+ }
+ if adoptBounceFn(svc) == nil {
+ t.Fatal("the adopt bounce must stay BounceAdopted")
+ }
+}
+
+func TestTheFlagOnArmsTheScheduler(t *testing.T) {
+ svc, reg, dd := rolloutFixture(t)
+ t.Cleanup(func() { rolloutMgr.Store(nil) })
+ _, pub, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ env := map[string]string{"CARLOS_SWAP": "overlap", "CARLOS_RELEASE_PUBKEY": pub}
+ store := release.NewDirStore(t.TempDir())
+ m := armRollout(context.Background(), func(k string) string { return env[k] }, svc, reg, dd, nil,
+ func(context.Context) (release.Store, error) { return store, nil })
+ if m == nil || armedRollout() != m {
+ t.Fatal("CARLOS_SWAP=overlap did not arm the scheduler")
+ }
+ if svc.Planner == nil || svc.OnGen == nil || svc.OnDrift == nil || svc.CoalesceRecycle == nil {
+ t.Fatal("a hook is missing")
+ }
+ if svc.GenerationsFile != release.GenerationsPath(dd) {
+ t.Fatalf("generations file = %q", svc.GenerationsFile)
+ }
+}
+
+func TestAnUnusableStoreLeavesTheBoxOnTodaysPaths(t *testing.T) {
+ svc, reg, dd := rolloutFixture(t)
+ m := armRollout(context.Background(), func(k string) string { return map[string]string{"CARLOS_SWAP": "overlap"}[k] }, svc, reg, dd, nil,
+ func(context.Context) (release.Store, error) { return nil, errors.New("no deployment store") })
+ if m != nil || svc.Planner != nil {
+ t.Fatal("armed without a deploy store: the canonical demand could not be read")
+ }
+}
+
+func TestRestartsFeedTheSchedulerWithTheirIdentity(t *testing.T) {
+ routes := []registry.Route{
+ {Host: "a.example.com", Kind: "instance", App: "blog", Account: "bab", Status: "ready", Hibernate: true},
+ {Host: "bot.internal", Kind: "sidecar", App: "blog", Account: "bab", Status: "ready"},
+ {Host: "u.example.com", Kind: "instance", App: "blog", Account: "bab", Status: "ready", Unit: "blog.service"},
+ {Host: "o.example.com", Kind: "instance", App: "other", Account: "bab", Status: "ready", Hibernate: true},
+ }
+ k := [2]string{"bab", "blog"}
+ got := map[string]string{}
+ n := feedRestarts(routes, [][2]string{k}, nil, map[[2]string]string{k: "etag-7"}, func(h, id string) bool { got[h] = id; return true })
+ if n != 2 || got["a.example.com"] != "gen:etag-7" || got["bot.internal"] != "gen:etag-7" {
+ t.Fatalf("fed %d: %v; want the instance and the sidecar, never the named unit or another app", n, got)
+ }
+}
+```
+
+- [ ] **Step 2: Run them to see them fail**
+
+Run: `TMPDIR=/tmp/k go test ./internal/host/ -run 'TestTheFlagOff|TestTheFlagOn|TestAnUnusableStore|TestRestartsFeed' -count=1`
+Expected: FAIL, `undefined: armRollout`.
+
+- [ ] **Step 3: Write the arming**
+
+Create `internal/host/rollout.go`:
+
+```go
+package host
+
+import (
+ "context"
+ "log"
+ "runtime"
+ "sync/atomic"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rollout"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+var rolloutMgr atomic.Pointer[rollout.Manager]
+
+// armedRollout is the box's rollout manager, nil unless CARLOS_SWAP=overlap
+// armed one. Every leg that would bounce an exec child asks it first.
+func armedRollout() *rollout.Manager { return rolloutMgr.Load() }
+
+// statusNudge asks the status loop for an early tick: a rollout transition
+// should reach status/boxes/<label>.json in seconds, not at the next
+// five-minute tick. Never sent to with the flag off.
+var statusNudge = make(chan struct{}, 1)
+
+// armRollout builds the scheduler when CARLOS_SWAP=overlap (spec §15) and
+// wires it into svc, which must not have started yet. With the flag off it
+// returns nil and touches nothing. With the flag on but no deploy store, no
+// public key or an unreadable failure book, it logs why and returns nil: the
+// box stays on today's paths, because each of those is a fact the scheduler
+// cannot do without (the canonical demand, the retry signal, the records).
+func armRollout(ctx context.Context, getenv func(string) string, svc *activator.Service, reg *registry.Registry, dd string,
+ sink activator.EventSink, openStore func(context.Context) (release.Store, error)) *rollout.Manager {
+ if svc == nil || !rollout.Enabled(getenv) {
+ return nil
+ }
+ store, err := openStore(ctx)
+ if err != nil {
+ log.Printf("agent: %s=overlap but the deploy store is unavailable (%v): swaps stay stop-then-wake", rollout.SwapEnv, err)
+ return nil
+ }
+ pub, err := release.ParsePublicKey(getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
+ if err != nil {
+ log.Printf("agent: %s=overlap but %v: swaps stay stop-then-wake", rollout.SwapEnv, err)
+ return nil
+ }
+ book, err := rollout.OpenFailureBook(dd)
+ if err != nil {
+ log.Printf("agent: %v: refusing to arm overlap swaps, a lost failure record could move instances back onto a failed build", err)
+ return nil
+ }
+ platform := runtime.GOOS + "-" + runtime.GOARCH
+ m := rollout.NewManager(ctx, rollout.Deps{
+ Act: svc, Routes: reg, Book: book, OptDir: release.OptDir(), DataDir: dd,
+ Demand: func(ctx context.Context, acct, app, ver, path string) (string, string, error) {
+ return release.CanonicalDemand(ctx, store, acct, app, ver, platform, path)
+ },
+ Policy: func(ctx context.Context, acct, app string) (*appconfig.Manifest, error) {
+ return appconfig.Get(ctx, store, acct, app)
+ },
+ Pointer: func(ctx context.Context, acct, app, channel string) (*release.ChannelPointer, error) {
+ return release.Channel(ctx, store, pub, acct, app, channel)
+ },
+ Events: sink,
+ Touch: func() { touchStageMarker(dd) },
+ OnChange: func() {
+ select {
+ case statusNudge <- struct{}{}:
+ default:
+ }
+ },
+ })
+ svc.Planner = m.Plan
+ svc.OnGen = m.OnGen
+ svc.OnDrift = m.Nudge
+ svc.CoalesceRecycle = m.CoalesceRecycle
+ svc.GenerationsFile = release.GenerationsPath(dd)
+ if routes, err := reg.List(); err == nil {
+ m.Bootstrap(routes) // spec §8.5: awake, healthy instances record their serving build
+ m.Pass(routes)
+ }
+ rolloutMgr.Store(m)
+ log.Printf("agent: overlap swaps armed (%s=overlap)", rollout.SwapEnv)
+ return m
+}
+
+// rolloutLegFor is the converge leg with the flag on (spec §10): the agent
+// pass of spec §6.1, every adoptedConvergeEvery, in place of convergeAdopted.
+func rolloutLegFor(m *rollout.Manager, reg *registry.Registry) func() {
+ return func() {
+ routes, err := reg.List()
+ if err != nil {
+ return
+ }
+ m.Pass(routes)
+ }
+}
+
+// feedRestarts is the restart leg's exec half with the flag on (spec §10):
+// each moved app's exec instances and sidecars, and each narrowed request's
+// hosts, get a restart attempt identified by the request (the app-wide
+// generation's etag, or the newest narrowed request's id). The root half,
+// named units and unit backing, is unchanged.
+func feedRestarts(routes []registry.Route, moved [][2]string, scoped []scopedRestart, gen map[[2]string]string, feed func(host, id string) bool) int {
+ want := map[[2]string]bool{}
+ for _, k := range moved {
+ want[k] = true
+ }
+ n := 0
+ for _, rt := range routes {
+ k := [2]string{release.RouteAccount(rt), rt.App}
+ if !want[k] || !restartEligible(rt) || !execBounceable(rt) {
+ continue
+ }
+ if feed(rt.Host, "gen:"+gen[k]) {
+ n++
+ }
+ }
+ for _, w := range scoped {
+ if len(w.requests) == 0 {
+ continue
+ }
+ id := "scope:" + w.requests[len(w.requests)-1].ID
+ for _, host := range scopedHosts(routes, w.app, w.requests, execBounceable) {
+ if feed(host, id) {
+ n++
+ }
+ }
+ }
+ return n
+}
+
+// rolloutStatusProbe feeds status.Probes.Rollout: nil with the flag off.
+func rolloutStatusProbe() *status.RolloutReport {
+ m := armedRollout()
+ if m == nil {
+ return nil
+ }
+ r := m.Status()
+ return &r
+}
+```
+
+`touchStageMarker` is Task 15's.
+
+- [ ] **Step 4: Wire it into `runAgent` and the legs**
+
+In `runAgent`, directly before `svc = activator.New(*cfg, &activator.SplitBackend{` (line 378), add:
+
+```go
+ // CARLOS_SWAP=overlap turns on generations (plan 1) and swaps
+ // (plan 3) before the Service exists, which reads both once.
+ swapOn := rollout.Enabled(os.Getenv)
+ activator.SetGenerations(swapOn)
+ cfg.Swap = swapOn
+```
+
+After `svc.Logs = logSink` (line 393, still before `startActivator(...)`), add:
+
+```go
+ var rolloutSink activator.EventSink
+ if logSink != nil { // a typed nil in the interface would not be nil
+ rolloutSink = logSink
+ }
+ armRollout(agentCtx, os.Getenv, svc, reg, dd, rolloutSink, openDeployStore)
+ if armedRollout() != nil {
+ armStageRelay(reg, dd)
+ }
+```
+
+In `fastConvergeLoop`, replace
+
+```go
+ if leg := convergeLegFor(svc, reg); leg != nil {
+ go runConvergeLoop(leg, adoptedConvergeEvery, nil)
+ }
+```
+
+with:
+
+```go
+ if m := armedRollout(); m != nil {
+ go runConvergeLoop(rolloutLegFor(m, reg), adoptedConvergeEvery, nil)
+ } else if leg := convergeLegFor(svc, reg); leg != nil {
+ go runConvergeLoop(leg, adoptedConvergeEvery, nil)
+ }
+```
+
+In `adoptBounceFn`, before `return svc.BounceAdopted`, add:
+
+```go
+ if m := armedRollout(); m != nil {
+ return m.Adopted
+ }
+```
+
+In `syncExecSerialized`, before its final `return appconfig.SyncExec(...)`, add:
+
+```go
+ if m := armedRollout(); m != nil {
+ // The scheduler restarts exec instances, paced, and acknowledges a
+ // revision only once it is active (spec §8.1, §10).
+ cb := appconfig.Callbacks{
+ Restart: func(string) error { return nil },
+ Delivered: m.ConfigDelivered,
+ Ack: m.DeferAck(configAckFunc(ctx, store, "agent")),
+ }
+ return appconfig.SyncExecWithCallbacks(ctx, reg, store, priv, dd, os.Getenv("PLATFORM_APPS_DOMAIN"), cb, opts...)
+ }
+```
+
+In `restart.go`'s `restartRequestLeg`, replace
+
+```go
+ bounceExecRoutes(routes, moved, bounce)
+ cycleWorkers(routes, moved, bounce, wake)
+```
+
+with
+
+```go
+ if m := armedRollout(); m != nil {
+ feedRestarts(routes, moved, scoped, mem.gen, m.Restart)
+ } else {
+ bounceExecRoutes(routes, moved, bounce)
+ cycleWorkers(routes, moved, bounce, wake)
+ }
+```
+
+and wrap the later `bounceScopedRoutes(routes, scoped, bounce)` the same way: `if armedRollout() == nil { bounceScopedRoutes(routes, scoped, bounce) }`.
+
+In `statusTick`'s probe block, after `p.ReleaseRefusals = releaseRefusalProbe()`, add `p.Rollout = rolloutStatusProbe()`. In `statusLoop`, replace `time.Sleep(delay)` with:
+
+```go
+ select {
+ case <-time.After(delay):
+ case <-statusNudge:
+ // A rollout transition (flag on only): publish within seconds,
+ // at most every two, so a busy rollout cannot hammer the bucket.
+ time.Sleep(2 * time.Second)
+ }
+```
+
+Add the `rollout` import to `agent.go`.
+
+- [ ] **Step 5: Run the tests**
+
+Run: `TMPDIR=/tmp/k go test ./internal/host/ -count=1`
+Expected: `ok github.com/carlosframework/platform/internal/host`
+
+- [ ] **Step 6: Commit**
+
+```bash
+go build ./... && go vet ./internal/host/ && test -z "$(gofmt -l internal/host)"
+git add internal/host/
+git commit -m "zdd-5 task 14: wire CARLOS_SWAP=overlap"
+```
+
+---
+
+### Task 15: Stage-relay retransmission
+
+**Files:**
+- Create: `internal/host/stagerelay.go`, `internal/host/stagerelay_test.go`
+- Modify: `internal/host/agent.go` (`stageRequester` at 1357)
+
+**Interfaces:**
+- Produces:
+ ```go
+ type stageRelay struct { /* pending map[string]string, get, touch */ }
+ func (r *stageRelay) note(host, ver string)
+ func (r *stageRelay) tick() bool
+ func armStageRelay(reg *registry.Registry, dd string)
+ func touchStageMarker(dd string)
+ ```
+
+Spec §7.7: "The agent touches it again every `stageRequestEvery` while any exec route's channel head isn't installed, so a touch missed during a running pass is repeated." The relay learns a channel head from the pass that could not stage it (`OnStageBlocked`), which runs only past bake holds; so it keeps asking until root's stamp shows that version installed, the route goes, or a newer head replaces it.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/host/stagerelay_test.go`:
+
+```go
+package host
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+func TestTheRelayAsksAgainUntilTheHeadIsInstalled(t *testing.T) {
+ rows := map[string]*registry.Route{"a": {Host: "a", Version: "v1"}, "b": {Host: "b", Version: "v1"}}
+ touches := 0
+ r := &stageRelay{pending: map[string]string{},
+ get: func(h string) (*registry.Route, error) { return rows[h], nil },
+ touch: func() { touches++ }}
+ if r.tick() || touches != 0 {
+ t.Fatal("touched with nothing outstanding")
+ }
+ r.note("a", "v2")
+ r.note("b", "v2")
+ if !r.tick() || touches != 1 {
+ t.Fatal("one touch per tick while heads are outstanding")
+ }
+ rows["a"].Version = "v2"
+ delete(rows, "b")
+ if r.tick() || touches != 1 {
+ t.Fatal("kept asking after root installed a's head and b's route went")
+ }
+ r.note("a", "v3")
+ r.get = func(string) (*registry.Route, error) { return nil, errors.New("database is locked") }
+ if !r.tick() {
+ t.Fatal("an unreadable row is not proof the head is installed")
+ }
+}
+```
+
+- [ ] **Step 2: Run it to see it fail**
+
+Run: `TMPDIR=/tmp/k go test ./internal/host/ -run TestTheRelayAsksAgain -count=1`
+Expected: FAIL, `undefined: stageRelay`.
+
+- [ ] **Step 3: Write the relay**
+
+Create `internal/host/stagerelay.go`:
+
+```go
+package host
+
+import (
+ "log"
+ "os"
+ "path/filepath"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// stageRelay retransmits the stage request (spec §7.7). carlos-stage.path
+// does not act on a change made while carlos-stage.service is running, so a
+// touch that lands mid-pass is lost; this asks again every stageRequestEvery
+// until root's stamp shows the head installed. Armed only with the flag on.
+type stageRelay struct {
+ mu sync.Mutex
+ pending map[string]string // host -> the channel head a pass could not stage
+ get func(host string) (*registry.Route, error)
+ touch func()
+}
+
+func (r *stageRelay) note(host, ver string) {
+ r.mu.Lock()
+ r.pending[host] = ver
+ r.mu.Unlock()
+}
+
+// tick touches the marker once if any head is still outstanding, and drops
+// the ones root has installed or whose route is gone.
+func (r *stageRelay) tick() bool {
+ r.mu.Lock()
+ snap := make(map[string]string, len(r.pending))
+ for h, v := range r.pending {
+ snap[h] = v
+ }
+ r.mu.Unlock()
+ outstanding := false
+ for host, ver := range snap {
+ rt, err := r.get(host)
+ if err != nil {
+ outstanding = true
+ continue
+ }
+ if rt == nil || rt.Version == ver {
+ r.mu.Lock()
+ if r.pending[host] == ver {
+ delete(r.pending, host)
+ }
+ r.mu.Unlock()
+ continue
+ }
+ outstanding = true
+ }
+ if outstanding {
+ r.touch()
+ }
+ return outstanding
+}
+
+var armedStageRelay atomic.Pointer[stageRelay]
+
+// touchStageMarker writes the stage-request marker, as stageRequester does.
+// A failed write costs latency; carlos-adopt.timer is the floor.
+func touchStageMarker(dd string) {
+ if err := os.WriteFile(filepath.Join(dd, stageRequestMarker), []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o644); err != nil {
+ log.Printf("agent: stage relay: %v", err)
+ }
+}
+
+// armStageRelay starts the retransmission loop for the life of the process.
+func armStageRelay(reg *registry.Registry, dd string) {
+ r := &stageRelay{pending: map[string]string{}, get: reg.Get, touch: func() { touchStageMarker(dd) }}
+ armedStageRelay.Store(r)
+ go func() {
+ t := time.NewTicker(stageRequestEvery)
+ defer t.Stop()
+ for range t.C {
+ r.tick()
+ }
+ }()
+}
+```
+
+In `stageRequester`, change the returned closure from `return func(host, ver string, _ error) { t.request(host, ver) }` to:
+
+```go
+ return func(host, ver string, _ error) {
+ t.request(host, ver)
+ if r := armedStageRelay.Load(); r != nil {
+ r.note(host, ver)
+ }
+ }
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `TMPDIR=/tmp/k go test ./internal/host/ -count=1`
+Expected: `ok github.com/carlosframework/platform/internal/host`
+
+- [ ] **Step 5: Commit**
+
+```bash
+go vet ./internal/host/ && test -z "$(gofmt -l internal/host)"
+git add internal/host/stagerelay.go internal/host/stagerelay_test.go internal/host/agent.go
+git commit -m "zdd-5 task 15: stage-relay retransmission"
+```
+
+---
+
+### Task 16: The invariant simulation, with mutation checks
+
+**Files:**
+- Create: `internal/rollout/sim_test.go`
+
+**Interfaces:**
+- Consumes: the real `appQueue` (Tasks 5, 6, 11), `FailureBook` in memory (Task 4), `failCohort` (Task 6), `decideWake` (Task 10). Only the activator is modelled.
+
+The invariants (spec §12 "Concurrency", and this plan's brief):
+- **I1:** never a release-cause failure that leaves an instance without a good build it can boot. Every wake of an instance that has a good build boots something, and no wake boots, for an instance that has not served it, a build that failed on this box.
+- **I2:** `swap_concurrency` is never exceeded.
+- **I3:** a failure record never blocks an instance already serving that build: its restart and config attempts are admitted whenever capacity and their own cohort allow, and no record is ever written against a build by an instance that serves it.
+- **I4:** a swap never switches to a superseded build (the index's review item 5).
+
+- [ ] **Step 1: Write the simulation**
+
+Create `internal/rollout/sim_test.go`:
+
+```go
+package rollout
+
+import (
+ "fmt"
+ "math/rand"
+ "os"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+type simHost struct {
+ name string
+ ch int
+ awake bool
+ serving, good string
+ served map[string]bool
+}
+
+type sim struct {
+ r *rand.Rand
+ q *appQueue
+ book *FailureBook
+ hosts []*simHost
+ byName map[string]*simHost
+ installed [2]string
+ bad map[string]bool // builds that fail when first run (ground truth)
+ failed map[string]bool // builds whose failure stands on this box (ground truth)
+ running map[string]dispatch
+ n int
+ now time.Time
+ step int
+}
+
+func newSim(seed int64) *sim {
+ s := &sim{r: rand.New(rand.NewSource(seed)), book: &FailureBook{}, byName: map[string]*simHost{},
+ bad: map[string]bool{}, failed: map[string]bool{}, running: map[string]dispatch{}, now: t0}
+ s.q = newAppQueue("bab", "blog", func(build, host string, served bool) bool {
+ return s.book.Blocks("bab", "blog", build, host, served)
+ })
+ s.q.concurrency = 1 + s.r.Intn(3)
+ s.installed = [2]string{"b0", "b0"}
+ for i := range 5 {
+ h := &simHost{name: "h" + strconv.Itoa(i), ch: i % 2, awake: s.r.Intn(2) == 0, served: map[string]bool{"b0": true}}
+ h.good = "b0"
+ if h.awake {
+ h.serving = "b0"
+ }
+ s.hosts = append(s.hosts, h)
+ s.byName[h.name] = h
+ s.q.observe(h.name, h.serving, h.good, s.now)
+ s.q.wantBuild(h.name, activator.CauseRelease, "b0", s.now)
+ }
+ return s
+}
+
+func (s *sim) failure(build, host string) {
+ if s.byName[host].served[build] {
+ panic("I3: a failure record against build " + build + ", which " + host + " serves")
+ }
+ failCohort(s.book, s.q, FailureRecord{Account: "bab", App: "blog", Build: build, Host: host, PromotedAt: s.now, At: s.now})
+ s.failed[build] = true // ground truth, whatever the book recorded
+}
+
+func (s *sim) pass() {
+ for _, h := range s.hosts {
+ if _, running := s.running[h.name]; !running {
+ s.q.observe(h.name, h.serving, h.good, s.now)
+ }
+ s.q.wantBuild(h.name, activator.CauseRelease, s.installed[h.ch], s.now)
+ }
+}
+
+func (s *sim) promote() {
+ s.n++
+ b := "b" + strconv.Itoa(s.n)
+ s.bad[b] = s.r.Intn(3) == 0
+ s.installed[s.r.Intn(2)] = b
+ s.pass()
+}
+
+func (s *sim) admit() {
+ view := map[string]string{}
+ for _, h := range s.hosts {
+ if s.r.Intn(10) == 0 {
+ continue // waking or stopping
+ }
+ view[h.name] = h.serving
+ }
+ for _, d := range s.q.admit(view, s.now) {
+ s.running[d.ID.Host] = d
+ }
+ if len(s.running) > s.q.concurrency || s.q.running > s.q.concurrency {
+ panic(fmt.Sprintf("I2: %d running with swap_concurrency %d", len(s.running), s.q.concurrency))
+ }
+ if s.q.running >= s.q.concurrency {
+ return
+ }
+ // I3, liveness: with capacity free, an awake instance's restart or config
+ // work onto the build it serves runs unless its own cohort's canary holds it.
+ for _, h := range s.hosts {
+ slot := s.q.hosts[h.name]
+ if _, seen := view[h.name]; !seen {
+ continue // waking or stopping this round: it keeps its place
+ }
+ if _, running := s.running[h.name]; running || !h.awake || slot.serving == "" {
+ continue
+ }
+ d, ok := s.q.pending(slot)
+ if !ok || d.ID.Build != slot.serving || s.now.Before(slot.configAfter) {
+ continue
+ }
+ c := s.q.cohorts[cohortKey(slot.serving, classRecover)]
+ if c == nil || c.canary == "" || c.settled {
+ panic("I3: " + h.name + "'s " + string(d.ID.Cause) + " onto the build it serves was held back")
+ }
+ }
+}
+
+func (s *sim) complete() {
+ if len(s.running) == 0 {
+ return
+ }
+ var host string
+ for host = range s.running {
+ break
+ }
+ d := s.running[host]
+ delete(s.running, host)
+ h := s.byName[host]
+ out := activator.SwapDone
+ switch {
+ case s.q.newest(host) != d.SchedRev:
+ out = activator.SwapAbandoned // Swap's re-check (spec §6.3)
+ case !h.served[d.ID.Build] && s.bad[d.ID.Build]:
+ out = activator.SwapFailed
+ case d.ID.Build == h.serving && s.r.Intn(8) == 0:
+ out = activator.SwapFailed // a restart or config attempt that fails
+ }
+ adopting := d.ID.Cause == activator.CauseRelease || d.ID.Cause == activator.CauseRetry
+ if out == activator.SwapDone && adopting && s.q.hosts[host].wantBuild != d.ID.Build {
+ panic("I4: " + host + " switched to " + d.ID.Build + " after its desired build moved to " + s.q.hosts[host].wantBuild)
+ }
+ transition := s.q.finish(d, out)
+ if out == activator.SwapDone {
+ h.serving, h.good = d.ID.Build, d.ID.Build
+ h.served[d.ID.Build] = true
+ s.q.observe(host, h.serving, h.good, s.now)
+ }
+ if transition {
+ s.failure(d.ID.Build, host)
+ }
+}
+
+func (s *sim) wake() {
+ h := s.hosts[s.r.Intn(len(s.hosts))]
+ if h.awake {
+ return
+ }
+ rt := registry.Route{Host: h.name, Kind: "instance", App: "blog", Account: "bab", Status: "ready", Hibernate: true,
+ Version: s.installed[h.ch], GoodVersion: h.good}
+ if h.good != "" {
+ rt.GoodDigest = "d-" + h.good
+ }
+ plan, _ := decideWake(wakeInputs{Route: rt, OptDir: "/opt/carlos", Policy: DefaultPolicy(), Book: s.book,
+ Demand: func(v, _ string) (string, string, error) { return "", "d-" + v, nil }})
+ boot := plan.Boot.Version
+ if h.good != "" && (plan.Refuse != "" || boot == "") {
+ panic("I1: " + h.name + " has good build " + h.good + " but its wake boots nothing: " + plan.Refuse)
+ }
+ if boot != "" && s.failed[boot] && !h.served[boot] {
+ panic("I1: " + h.name + " boots " + boot + ", which failed on this box")
+ }
+ if boot == "" {
+ return
+ }
+ if plan.FirstWake && s.bad[boot] {
+ if plan.Fallback == nil {
+ s.failure(boot, h.name)
+ return
+ }
+ h.awake, h.serving = true, plan.Fallback.Version
+ s.failure(boot, h.name)
+ } else {
+ h.awake, h.serving = true, boot
+ if plan.FirstWake {
+ h.served[boot], h.good = true, boot
+ }
+ }
+ s.q.observe(h.name, h.serving, h.good, s.now)
+}
+
+func (s *sim) sleep() {
+ h := s.hosts[s.r.Intn(len(s.hosts))]
+ if _, running := s.running[h.name]; running || !h.awake {
+ return
+ }
+ h.awake, h.serving = false, ""
+ s.q.observe(h.name, "", h.good, s.now)
+}
+
+func (s *sim) retry() {
+ for b := range s.failed {
+ if ok, _ := s.book.ClearOnPointer("bab", "blog", b, s.now.Add(time.Second)); ok {
+ s.q.unhalt(b)
+ delete(s.failed, b)
+ }
+ return
+ }
+}
+
+func (s *sim) run(steps int) {
+ for s.step = 0; s.step < steps; s.step++ {
+ s.now = s.now.Add(time.Second)
+ switch k := s.r.Intn(20); {
+ case k < 2:
+ s.promote()
+ case k < 7:
+ s.admit()
+ case k < 12:
+ s.complete()
+ case k < 14:
+ s.wake()
+ case k < 15:
+ s.sleep()
+ case k < 16:
+ h := s.hosts[s.r.Intn(len(s.hosts))]
+ if h.awake {
+ s.n++
+ s.q.wantRestart(h.name, "r"+strconv.Itoa(s.n), s.now)
+ }
+ case k < 17:
+ h := s.hosts[s.r.Intn(len(s.hosts))]
+ if h.awake {
+ s.n++
+ s.q.wantConfig(h.name, "c"+strconv.Itoa(s.n), s.now)
+ }
+ case k < 18:
+ s.retry()
+ default:
+ s.pass()
+ }
+ }
+}
+
+// TestSimInvariants drives the real queue, failure book, cohort-failure
+// transition and wake planner against a modelled activator. Seeds come from
+// ROLLOUT_SIM_SEEDS (default 200; the per-revision sweep is 3000).
+func TestSimInvariants(t *testing.T) {
+ seeds := 200
+ if v, err := strconv.Atoi(os.Getenv("ROLLOUT_SIM_SEEDS")); err == nil && v > 0 {
+ seeds = v
+ }
+ for seed := range int64(seeds) {
+ func() {
+ s := newSim(seed)
+ defer func() {
+ if r := recover(); r != nil {
+ t.Fatalf("seed %d step %d: %v", seed, s.step, r)
+ }
+ }()
+ s.run(400)
+ }()
+ }
+}
+```
+
+- [ ] **Step 2: Run it**
+
+Run: `uptime && nice -n 19 go test ./internal/rollout/ -run TestSimInvariants -count=1 -p 1`
+Expected: `ok github.com/carlosframework/platform/internal/rollout`. A failure names its seed and step: fix the code, never the invariant.
+
+- [ ] **Step 3: Run the per-revision sweep**
+
+Check `uptime` first; if the load average is above 12, wait. Then:
+
+```bash
+ROLLOUT_SIM_SEEDS=3000 nice -n 19 go test ./internal/rollout/ -run TestSimInvariants -count=1 -p 1 -timeout 30m
+```
+Expected: `ok`. One sweep per revision; deeper runs only when Paul asks.
+
+- [ ] **Step 4: Prove each invariant can fail (mutation checks)**
+
+Commit first (Step 5 below can come before this step), because the copy is made from `HEAD`. Each mutation edits a scratch copy under `$TMPDIR`, never the worktree, and each must make the simulation FAIL. Run them one at a time:
+
+```bash
+MUT="$TMPDIR/zdd5-mut"
+mutate() { # $1 name, $2 file, $3 sed program, $4 the invariant it should break
+ rm -rf "$MUT" && mkdir -p "$MUT" && git archive HEAD | tar -x -C "$MUT"
+ before=$(md5sum "$MUT/$2")
+ sed -i "$3" "$MUT/$2"
+ [ "$before" != "$(md5sum "$MUT/$2")" ] || { echo "$1: sed matched nothing, fix the program"; return 1; }
+ if (cd "$MUT" && GOFLAGS=-buildvcs=false nice -n 19 go test ./internal/rollout/ -run TestSimInvariants -count=1 -p 1 2>&1 | grep -q "$4"); then
+ echo "$1: killed ($4)"
+ else
+ echo "$1: SURVIVED"
+ fi
+}
+mutate M1 internal/rollout/queue.go 's/if q.running >= q.concurrency {/if false \&\& q.running >= q.concurrency {/' I2
+mutate M2 internal/rollout/queue.go 's/class := classRecover/class := classAdopt/' I3
+mutate M3 internal/rollout/queue.go 's/added, err = book.Record(rec)/added, err = true, nil/' I1
+mutate M4 internal/rollout/queue.go '/h.wantBuild, h.wantCause = build, cause/{n;s/h.buildRev = q.nextRev()/_ = q.nextRev()/}' I4
+mutate M5 internal/rollout/wakeplan.go 's/if in.Book.Blocks(acct, app, installed, host, false) {/if false \&\& in.Book.Blocks(acct, app, installed, host, false) {/' I1
+rm -rf "$MUT"
+```
+
+Expected: five lines ending `killed (I2)`, `killed (I3)`, `killed (I1)`, `killed (I4)`, `killed (I1)`. A `SURVIVED` line means the simulation cannot see that invariant break: strengthen the simulation (more steps, or the step that would exercise it) until it is killed. If a mutated copy does not compile, the sed changed the wrong line: fix the program, not the code.
+
+- [ ] **Step 5: Commit**
+
+```bash
+go vet ./internal/rollout/ && test -z "$(gofmt -l internal/rollout)"
+git add internal/rollout/sim_test.go
+git commit -m "zdd-5 task 16: invariant simulation for the scheduler"
+```
+
+---
+
+### Task 17: The gate, and handing the branch for review
+
+**Files:** none new.
+
+- [ ] **Step 1: Run the whole gate**
+
+```bash
+uptime
+mkdir -p /tmp/k
+go build ./... && go vet ./... && test -z "$(gofmt -l .)"
+TMPDIR=/tmp/k nice -n 19 go test ./... -count=1 -p 2
+GOOS=darwin GOARCH=arm64 go build ./... && GOOS=windows GOARCH=amd64 go build ./... && GOOS=linux GOARCH=arm64 go build ./...
+```
+Expected: no output from `gofmt`, every package `ok`, and three silent cross-builds.
+
+- [ ] **Step 2: Check the flag-off promise (cross-plan review focus 1)**
+
+```bash
+grep -rn 'CARLOS_SWAP\|rollout.Enabled' --include=*.go internal | grep -v _test.go
+```
+Expected: `rollout.Enabled` is read only in `internal/host/agent.go` (the `SetGenerations`/`Config.Swap` line) and `internal/host/rollout.go`, and `SwapEnv` only in `internal/rollout`. With the flag unset, `TestTheFlagOffArmsNothing` (Task 14) and `TestWithoutAPlannerTheWakeIsTodays` (Task 9) are the proof.
+
+- [ ] **Step 3: Push and describe**
+
+```bash
+git push origin zdd-5-scheduler
+amadan branch describe carlos/platform zdd-5-scheduler -body - <<'EOF'
+Zero-downtime deploys plan 5: the rollout scheduler, and the CARLOS_SWAP=overlap wiring.
+Flag off: byte-identical (Tasks 9 and 14 pin it). Flag on: per-app schedulers, canaries,
+swap_concurrency, failure records with promote --retry clearing, first-wake budget and
+fallback, config activation, the generations file root reads, stage-relay retransmission.
+Event wording is draft until plan 6's copy review; keep the flag off outside dev until then.
+Simulation: 3000 seeds, mutations M1-M5 killed.
+EOF
+amadan ci status carlos/platform -branch zdd-5-scheduler
+```
+Expected: `amadan ci status` exits 0 once CI finishes (2 while pending). Do not merge: the branch waits for review (a whole-branch review on the most capable model, per AGENTS.md's delegation policy), then squash and `amadan branch merge carlos/platform zdd-5-scheduler -expect <tip> -message "..."`.
+
+---
+
+## Self-review
+
+- **Spec coverage.** §4.1 policy keys: Task 3. §4.2 modes, canonical demand, retained identity, recovery mode with current policy, staging refusal and relay touch: Tasks 3, 8, 10. §6.1 one scheduler per app off the tick, per-instance desired builds, the four feeds: Tasks 5, 8, 14. §6.2 sleepers move at once, canary and settle, `swap_concurrency`, waking instances keep their place: Tasks 6, 8. §6.3 identity, dedupe, supersession, coalescing, newest check: Tasks 5, 8, 16. §6.4 first-wake canary, intentional stops, hold never falls back: Task 9. §6.5 socketless: Tasks 9, 11. §7.7 retransmission: Task 15. §7.9 coalescing and the drift check as a release attempt: Tasks 9, 11. §8.1 cohort-failure transition and its order, restart and config failures, delivered and active config with 1 to 30 minute backoff: Tasks 6, 7, 8. §8.2 the budget, the kill, the fallback, wake-time choice without a repoint: Tasks 9, 10. §8.3 hold failures held down: Tasks 8, 10. §8.4 records, scope, status, clearing by newer build, host retry, `promote --retry`: Tasks 4, 8, 13. §8.5 `GoodVersion` triple, expected-current writes, bootstrap, pins: Tasks 2, 8, 12. §9.4 events: Tasks 8, 9 (drafts). §10 converge leg, sweep, restart, config: Tasks 9, 14. §12 Scheduler, Config activation, Failure-hold scope, Failure without a repoint, Fallback, Bootstrap, Recovery mode, Lifecycle versus settle, Concurrency simulation: the tests named in those tasks.
+- **Not here:** surfaces, CLI and console doors, final event wording, the `retry` selector on the narrowed-restart log (plan 6); the swap mechanics (plan 3).
+- **Placeholder scan:** none. Steps that depend on plans 1 to 4 name the code to find and the exact change.
+- **Type consistency:** `Identity`, `dispatch`, `appQueue`, `FailureRecord`, `WakePlan`, `GenEvent`, `RolloutReport` and the `Deps` fields are used with one spelling throughout; Task 1 pins every outside name.
+- **Review Focus:** each of the five items has its test in the task named.