carlos / platform Public

plans: Astra P4 fixed (claims-on paths through the helpers, worker host-cgroup proof, config activation order, flag-off serving identity)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev fd261a6afe3d05aa581e3e55ab5a0a1a78be3ea7 parent fd741a7
4 files changed, +864 −91
  • docs/superpowers/plans/2026-10-07-zero-downtime-deploys-0-index.md +43 −3
  • docs/superpowers/plans/2026-10-07-zero-downtime-deploys-1-generations.md +43 −21
  • docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md +558 −58
  • docs/superpowers/plans/2026-10-07-zero-downtime-deploys-5-scheduler.md +220 −9

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-0-index.md b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-0-index.md
index 2658f6094..55c9c9c1b 100644
--- a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-0-index.md
+++ b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-0-index.md
@@ -401,6 +401,43 @@ Per finding:
request is on a connection to the chosen generation; `dialGen` rejects a
connection whose generation changed before it completed.
+## Fixes for Astra's P4 review of the plans (2026-10-08)
+
+`CODEX-FINDINGS-ZDD-PLANS-P4.md`: five partial P3 fixes and four new
+findings. The claims-on paths now go through the same three helpers as the
+claims-off ones, each with its own test:
+
+- **Termination proof** (`genTerminated`): a claims-on generation is
+ terminated only when its own `Stop` completed (`claimProcess.provenStopped`,
+ plan 3 Task 2). A leader that exited after `KillAndEmpty` failed is not.
+- **Attempt gate** (`attemptCurrentLocked`): a claims-on hold's pinned wake
+ runs it in `startAdmitted`'s `beforeStart` at every exec
+ (`StartPolicy.Gate`, plan 3 Tasks 6 and 10), not only before the
+ admission.
+- **Outcome classifier** (`classifyStep`): `startAdmitted` records
+ `pinFailed` and falls back only on `stepFailed` (plan 3 Task 10).
+
+Per finding:
+
+- **P1 #6, completed** (plan 5 Tasks 9, 10): a first wake's failing
+ pointer is read by the planner (`WakePlan.FailedUnder`) and travels on
+ the event (`GenEvent.FailedUnder`); `buildFailed` does no read.
+- **P2 #3, completed** (plan 3 Task 7): an unproven discarded or co-stopped
+ sidecar stays tracked (`straySidecars`), blocks the host's next wake and
+ is re-stopped by the sweep. With generations on, `stop()` joins an
+ in-flight sidecar start with no timeout.
+- **1** (plan 3 Task 11, plan 5 Task 9): a worker generation (`Gen.Worker`)
+ is proven on the cgroup it runs in, its host cgroup (`KillHost`), at a
+ terminal stop, at a respawn's retire, at a shed, and in `killFailed`
+ before a fallback. A failed proof keeps ownership.
+- **2** (plan 5 Task 8): `done` publishes config activation before it frees
+ the slot, with no registry read in front of it.
+- **3** (plan 5 Tasks 11, 14): `Service.OnWorkerServing` publishes a
+ worker's captured identity on every wake and respawn whatever the flag.
+ The agent writes it to `ServingVersion`, which `carlos sidecars ls` reads.
+- **4** (plan 1 Task 5): with no current generation, `RoundTrip` waits for
+ one (`waitServingGen`) and then takes the normal attributed path.
+
## Cross-plan interface ledger
Every type, function, method or field that one plan defines and another
@@ -429,7 +466,7 @@ many line numbers in plans 1, 3 and 5; find each use by its quoted name.
| `func (b *ExecBackend) prepareAt(rt, role instancectl.Role, sock string, gen uint64) (…)` (plan 2 adds `gen`) | P1 T2, P2 T11 | P3 T9 |
| `func (b *ExecBackend) normalizeSocketMode(rt registry.Route, sock string, p Proc)` | P1 T2 | P3 T1 (`GenSwitched`, `GenReady`) |
| `servingProc` (test type in `canonlink_test.go`) | P1 T4 | P1 only; plan 3's own fixture is `genServingProc` (P1 review finding 16) |
-| `GenPools` internals: `genPool{tr, gen}`, `dialGen` (dials only its own generation's socket and rejects a new connection unless that generation is still current, rev 17 §7.2), `genMovedError`, `heldBody` (the transport cannot read or close the body before the request is on a connection, so a switch retry never needs `GetBody`; `replayable` is gone) | P1 T5 | P1 only; plan 1's Tasks 6 and 8 use only `NewGenPools`, `Retire`, `Len`, `AppSocketProxy` |
+| `GenPools` internals: `genPool{tr, gen}`, `dialGen` (dials only its own generation's socket and rejects a new connection unless that generation is still current, rev 17 §7.2), `genMovedError`, `heldBody` (the transport cannot read or close the body before the request is on a connection, so a switch retry never needs `GetBody`; `replayable` is gone), `waitServingGen` (no current generation: wait, then the attributed path) | P1 T5 | P1 only; plan 1's Tasks 6 and 8 use only `NewGenPools`, `Retire`, `Len`, `AppSocketProxy` |
| `func (b *ExecBackend) listenControl(rt registry.Route, control, envPath string) *edge.ControlListener` | P1 T2 | P3 T8 L3586, L3822 |
| `actEntry.gen Gen`; `wokenInstance.gen Gen` | P1 T3 | P3 T4 L1799, T6 L3048, T11 L5233; P5 T1 L188–189, T9 L4712, L5030 |
| `Service.gens bool` (read once in `New`); `func (a *Service) GenerationsOn() bool` | P1 T3 | P3 T2 L706, T4 L1761, T10 L4734, T11 L5236; P5 T1 L184, T9 L4340 |
@@ -473,8 +510,9 @@ many line numbers in plans 1, 3 and 5; find each use by its quoted name.
| `func (a *Service) swapChecksLocked(e *actEntry, at Attempt, newest func(string) uint64) error` (now `attemptCurrentLocked(e, at, newest, false)`); `errSwapNotAwake`, `errSwapBusy`, `errSwapKind`, `errSwapStopped`; `func abandon(err error) SwapResult` | P3 T3, T4 | P5 T1 L172, T11 (`ReplaceWorker`, its `attemptGate`) |
| **The attempt gate**: `func (a *Service) attemptCurrentLocked(e *actEntry, at Attempt, newest func(string) uint64, waking bool) error`; `memberCurrentLocked(e, m, mctx, at, newest)`. Every exec point and completion path asks it: overlap Step 3a, the switch, `spawnSuccessor`'s claims `before` hook, a hold's `pinCheck` and result, `ReplaceWorker` | P3 T3 | P3 T4, T6, T10; P5 T11 (through `swapChecksLocked`) |
| **The outcome classifier**: `type stepOutcome`; `stepOK`, `stepCancelled`, `stepStale`, `stepFailed`; `func classifyStep(lifecycle context.Context, err error) stepOutcome`; `stepResult` | P3 T4 | P3 T4, T6; P5 T9 (`spawnPlanned`, `spawnPlannedWorker`: only `stepFailed` records a failure or falls back) |
-| **The termination proof**: `func (a *Service) genTerminated(rt registry.Route, g Gen, p Proc) bool` | P3 T2 | every lease- or set-giving path in P3 (member stop, reaper, `stop()`, `reapCurrentGen`, `cleanupFailedWake`, `reapUnprovenWakesLocked`) |
-| `func (a *Service) discardSidecar(sc Proc)`; `func sidecarJoinBound(gens bool) time.Duration` | P3 T7 | P3 only |
+| **The termination proof**: `func (a *Service) genTerminated(rt registry.Route, g Gen, p Proc) bool`; claims on: `func (p *claimProcess) provenStopped(ctx) bool`; workers: `Gen.Worker bool`, `type hostKiller interface{ KillHost(ctx, rt) error }`, `ExecBackend.KillHost`, `SplitBackend.KillHost` (P3 T11) | P3 T2, T11 | every lease- or set-giving path in P3 (member stop, reaper, `stop()`, `reapCurrentGen`, `cleanupFailedWake`, `reapUnprovenWakesLocked`, a worker respawn's retire and shed); P5 T9 (`killFailed` for a worker) |
+| `StartPolicy.Gate func() error` (the attempt gate at every claims-on exec, in `startAdmitted`'s `beforeStart`) | P3 T10 (set by T6's `doClaimWake` hold block) | P3 only |
+| `func (a *Service) discardSidecar(host string, sc Proc)`; `trackStraySidecar`, `reapStraySidecarsLocked`, `Service.straySidecars` (`sidecarJoinBound` is gone: with generations on the join has no timeout) | P3 T7 | P3 only |
| `actEntry.ops opSet` (`members map[uint64]*genMember`, `genMember.gen`); `addMemberLocked`; `leaveSet` | P3 T2 | P5 T1, T12 L6170, L6366, L6419 |
| `swapOverlap`'s `// Step 3a` gate block, before `g, proc, spawnErr := a.spawnSuccessor(mctx, gb, e, rt, work, m.gen, gate)` (`spawnSuccessor` and `startClaimedSuccessor` gain `gate func() error`) | P3 T4, T10 | P5 T12 (persist point, before Step 3a; a failed write abandons the attempt there) |
| `Service.genHealth func(ctx context.Context, g Gen) error` (test seam) | P3 T4 | P5 T1 L186, T9 L4344 |
@@ -517,6 +555,8 @@ many line numbers in plans 1, 3 and 5; find each use by its quoted name.
| `dispatch.FailedUnder time.Time` (the failing pointer's PromotedAt, read before the attempt runs) | P5 T5, T8 | P5 only |
| `Manager.served`, `publishServing`, `acknowledged`, `writeGood`/`writeGoodLocked`, `Manager.goodMu` (every serving- and good-build write is for the generation the activator serves, serialised; `done` publishes before it frees the slot) | P5 T8 | P5 T10 (`OnGen`) |
| `generationsLocked` includes `unprovenWakes` | P5 T12 | consumes P3 T2's `unprovenWakes` |
+| `WakePlan.FailedUnder`, `GenEvent.FailedUnder` (a first wake's failing pointer, read by the planner before the wake runs) | P5 T9, T10 | P5 only (`buildFailed`) |
+| `Service.OnWorkerServing func(host string, id release.StagedIdentity)` (flag-independent; the agent writes `ServingVersion` with it) | P5 T11, T14 | P6 T13 (`carlos sidecars ls` reads `ServingVersion`) |
| `func plannedWorkerReady(ctx context.Context, p Proc) error` (spec §6.5 for planned socketless wakes) | P5 T9 | P5 only |
| `func (a *Service) persistGenerations() error`; `errGenerationsUnpublished` (a spawn whose build is not published does not run) | P5 T12 | P5 only |
| `status.RolloutReport`, `HostRollout`, `AppRollout`, `RolloutFailure`; `Attempt*` and `Reason*` words; `Probes.Rollout`, `BoxMetrics.Rollout`; `func reasonWord(text string) string`; `Deps.OnChange` | P5 T13 | P6 T2, T8 L2110–2183, T10 L2779, T11 L3670, L4022, T12 L4064 (through `rolloutView`), T14 L4553 |
diff --git a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-1-generations.md b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-1-generations.md
index 601279e82..1a2e3c7b9 100644
--- a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-1-generations.md
+++ b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-1-generations.md
@@ -2232,6 +2232,7 @@ git commit -m "zdd plan 1 task 4: the canonical socket path is a symlink to the
func dialUnixRetrying(ctx context.Context, d *net.Dialer, resolve func() (string, bool)) (net.Conn, error)
type genMovedError struct{ to GenInfo } // a pool's own generation stopped being current before its dial completed
type heldBody struct{ … } // the request body, held from the transport until the request is on a connection
+ func waitServingGen(ctx context.Context, src GenSource, host string) (GenInfo, bool) // no current generation: wait for one, then the attributed path
func dialGen(ctx context.Context, d *net.Dialer, src GenSource, host string, own GenInfo) (net.Conn, error)
type genPool struct{ tr *http.Transport; gen GenInfo }
type GenPools struct{ … }
@@ -2637,7 +2638,9 @@ func TestAppSocketProxyNotesTheServedGeneration(t *testing.T) {
}
// With no current generation the request waits inside the dial's window for
-// one, then reaches it unpooled and untagged.
+// one, then goes the normal way: through that generation's pool, stamped
+// with its build and tagged with its number (spec §7.2). An unbound one-shot
+// transport returned the response unattributed, with no platform version.
func TestAppSocketProxyWaitsForAGeneration(t *testing.T) {
dir := genSockDir(t)
a := startGenApp(t, filepath.Join(dir, "a.sock"), body("A", ""))
@@ -2651,17 +2654,14 @@ func TestAppSocketProxyWaitsForAGeneration(t *testing.T) {
r, _ := withOutcome(httptest.NewRequest("GET", "http://inst.example.com/", nil))
rec := httptest.NewRecorder()
p.ServeHTTP(rec, r)
- if rec.Body.String() != "A" {
- t.Fatalf("body %q, want A once the generation appeared", rec.Body.String())
- }
- if _, _, ok := servedGen(r); ok {
- t.Fatal("a request that started with no current generation was tagged")
+ if rec.Body.String() != "A" || rec.Header().Get("X-Carlos-Version") != "v1" {
+ t.Fatalf("body %q, version %q: want A from v1 once the generation appeared", rec.Body.String(), rec.Header().Get("X-Carlos-Version"))
}
- if pools.Len() != 0 {
- t.Fatalf("%d pools made for an unpooled request", pools.Len())
+ if host, n, ok := servedGen(r); !ok || host != "inst.example.com" || n != 3 {
+ t.Fatalf("servedGen = %q %d %v, want generation 3", host, n, ok)
}
- if src.askCount() < 2 {
- t.Fatalf("AppSocket asked %d times, want a retry that asked again", src.askCount())
+ if pools.Len() != 1 {
+ t.Fatalf("%d pools, want generation 3's", pools.Len())
}
}
```
@@ -2902,10 +2902,10 @@ func (p *GenPools) Len() int {
}
// genTransportFor builds the transport behind one pool: unixProxy's idle
-// bounds, and a dial that reaches own's socket only (dialGen). With own nil
-// it is the one-shot transport for a request that reached no current
-// generation: keep-alives off, and a dial that resolves the socket per
-// attempt, waiting for a generation to appear.
+// bounds, and a dial that reaches own's socket only (dialGen). RoundTrip
+// always passes own: a request that finds no current generation waits for
+// one (waitServingGen) and then uses that generation's pool. With own nil
+// it is a one-shot transport whose dial resolves the socket per attempt.
func genTransportFor(host string, src GenSource, own *GenInfo) *http.Transport {
dialer := &net.Dialer{}
dial := func(ctx context.Context, _, _ string) (net.Conn, error) {
@@ -2957,14 +2957,17 @@ func (t *genRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
for attempt := 0; ; attempt++ {
g, ok := t.src.ServingGen(t.host)
if !ok {
- // No current generation: the dial waits for one inside its retry
- // window. Nothing is pooled and nothing is stamped or cached,
- // because which generation answers is not known here.
- resp, err := genTransportFor(t.host, t.src, nil).RoundTrip(req)
- if err != nil && hb != nil {
- hb.release()
+ // No current generation (between a stop and the next wake): wait
+ // for one inside the dial's retry window, then take the normal
+ // path, so the response is attributed to the generation that
+ // serves it and its connection passes dialGen's check (spec §7.2).
+ g, ok = waitServingGen(req.Context(), t.src, t.host)
+ if !ok {
+ if hb != nil {
+ hb.release()
+ }
+ return nil, errNoGeneration
}
- return resp, err
}
gp := t.pools.pool(t.host, g, t.src)
resp, err := gp.tr.RoundTrip(req)
@@ -2992,6 +2995,25 @@ func (t *genRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
}
}
+// waitServingGen waits, within the dial's 13 s retry window and ctx, for
+// host to have a current generation, asking every 100 ms.
+func waitServingGen(ctx context.Context, src GenSource, host string) (GenInfo, bool) {
+ deadline := time.Now().Add(13 * time.Second)
+ for {
+ if g, ok := src.ServingGen(host); ok {
+ return g, true
+ }
+ if time.Now().After(deadline) {
+ return GenInfo{}, false
+ }
+ select {
+ case <-ctx.Done():
+ return GenInfo{}, false
+ case <-time.After(100 * time.Millisecond):
+ }
+ }
+}
+
// heldBody is a request body the transport cannot consume or close before
// the request is on a connection. The transport reads a body only while it
// writes the request on a dialled connection, and closes it on any error,
diff --git a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md
index 3d14ddad8..b5758edec 100644
--- a/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md
+++ b/docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md
@@ -999,7 +999,7 @@ git commit -m "zdd plan 3 task 1: swap types, the SwapBackend seam, flat staged
- `finishPendingStops`, lines 2301–2328;
- `Shutdown`, lines 2921–2936;
- `doWake`'s four `cleanupFailedWake` calls and its head (the unproven-wake refusal).
-- Modify: `internal/activator/maintenance.go` (`cleanupFailedWake`)
+- Modify: `internal/activator/maintenance.go` (`cleanupFailedWake`); `internal/activator/claimwake.go` (`claimProcess.provenStopped`)
- Test: `internal/activator/opset_test.go`
**Interfaces:**
@@ -1020,6 +1020,7 @@ git commit -m "zdd plan 3 task 1: swap types, the SwapBackend seam, flat staged
func (a *Service) stopMember(e *actEntry, m *genMember) bool // leader exit AND an empty generation child
func (a *Service) genChildEmpty(rt registry.Route, m *genMember) bool
func (a *Service) genTerminated(rt registry.Route, g Gen, p Proc) bool // THE termination proof: leader exited and child empty; every lease- or set-giving path asks it
+ func (p *claimProcess) provenStopped(ctx context.Context) bool // claims-on: its own Stop completed (KillAndEmpty, exit, settle, close)
func (a *Service) reapCurrentGen(e *actEntry) // a pending stop's current generation, proven empty before finishStop
type unprovenWake struct{ rt registry.Route; gen Gen; proc Proc; lease Lease; rep Rep; busy bool }
var errWakeUnproven error
@@ -1046,6 +1047,7 @@ import (
"context"
"errors"
"fmt"
+ "os/exec"
"slices"
"sync"
"testing"
@@ -1299,6 +1301,60 @@ func TestMemberStopWithoutContainmentAcceptsTheLeaderExit(t *testing.T) {
}
}
+// failingContainment is a claims-on child whose KillAndEmpty fails until
+// heal is called.
+type failingContainment struct {
+ mu sync.Mutex
+ bad bool
+}
+
+func (c *failingContainment) KillAndEmpty(context.Context) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.bad {
+ return errors.New("cgroup.kill: the child is not empty")
+ }
+ return nil
+}
+func (c *failingContainment) Close() error { return nil }
+func (c *failingContainment) heal() {
+ c.mu.Lock()
+ c.bad = false
+ c.mu.Unlock()
+}
+
+// A claims-on generation is terminated only when its own Stop completed
+// (spec §7.4): KillAndEmpty on its child included. Here the leader has
+// exited but KillAndEmpty fails, so the member stays in the set, unproven,
+// until the child is empty. Treating any claimProcess as proven accepted
+// the leader's exit alone.
+func TestAClaimsOnMemberNeedsItsChildEmptied(t *testing.T) {
+ x := awakeForBarrier(t, true)
+ cmd := exec.Command("true")
+ if err := cmd.Run(); err != nil {
+ t.Skipf("no true(1): %v", err)
+ }
+ done := make(chan struct{})
+ close(done) // the leader has exited
+ cont := &failingContainment{bad: true}
+ p := &claimProcess{cmd: cmd, prepared: cont, done: done}
+ m := &genMember{gen: Gen{N: 11, Build: "v2"}, role: roleRetiring, proc: p, done: make(chan struct{})}
+ x.a.mu.Lock()
+ e := x.a.entries[x.host]
+ x.a.addMemberLocked(e, m)
+ x.a.mu.Unlock()
+ if x.a.dropMember(e, m) {
+ t.Fatal("a claims-on generation whose KillAndEmpty failed was taken as terminated")
+ }
+ cont.heal()
+ x.a.sweepOnce()
+ select {
+ case <-m.done:
+ case <-time.After(5 * time.Second):
+ t.Fatal("the member never left once its child was empty")
+ }
+}
+
// The current generation is held to the same proof (spec §7.4): a terminal
// stop whose main process has exited keeps the lease while that
// generation's child is not proven empty, and finishes once it is. A stop
@@ -1404,7 +1460,7 @@ func TestShutdownLeavesAWakingEntryWithSwapOff(t *testing.T) {
- [ ] **Step 2: Run the tests to verify they fail**
-Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapWitnessBarrier|TestBarrierKeeps|TestMemberStop|TestSwapWitnessMemberStop|TestSwapWitnessStopProves|TestSwapWitnessShutdownJoins|TestShutdownLeaves' -count=1`
+Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapWitnessBarrier|TestBarrierKeeps|TestMemberStop|TestSwapWitnessMemberStop|TestSwapWitnessStopProves|TestAClaimsOnMember|TestSwapWitnessShutdownJoins|TestShutdownLeaves' -count=1`
Expected: build failure: `undefined: genRole`, `undefined: genMember`, `x.a.entries[x.host].ops undefined`.
- [ ] **Step 3: Write `opset.go`**
@@ -1586,11 +1642,14 @@ func (a *Service) genTerminated(rt registry.Route, g Gen, p Proc) bool {
// is the proof (plan 2, "stop evidence on the parent"). Any other error is
// an unproven termination.
func (a *Service) genChildEmpty(rt registry.Route, m *genMember) bool {
- if _, ok := m.proc.(*claimProcess); ok {
- // A claims-on generation (Task 10): claimProcess.Stop already ran
- // KillAndEmpty on its own child and succeeded, or Stop failed and
- // stopMember never got here.
- return true
+ if cp, ok := m.proc.(*claimProcess); ok {
+ // A claims-on generation (Task 10): its own Stop is the proof, and
+ // only a Stop that completed (KillAndEmpty, the exit, the settle and
+ // the close) counts. A leader that exited after KillAndEmpty failed
+ // is not terminated.
+ ctx, cancel := context.WithTimeout(context.Background(), instancectl.DeadlineControl)
+ defer cancel()
+ return cp.provenStopped(ctx)
}
k, ok := a.backend.(genKiller)
if !ok || m.gen.N == 0 {
@@ -1861,6 +1920,23 @@ At the end of `stop()`, replace `a.finishStop(e, outcome)` (line 2268) with:
a.finishStop(e, outcome)
```
+Add to `claimwake.go`, beside `claimProcess.Stop`:
+
+```go
+// provenStopped is a claims-on generation's termination proof (zero-downtime
+// spec §7.4): its Stop has completed, which takes KillAndEmpty on its own
+// child, the exit, the settle and the close. Stop is idempotent once
+// closed, so this retries a Stop that failed before.
+func (p *claimProcess) provenStopped(ctx context.Context) bool {
+ if err := p.Stop(ctx); err != nil {
+ return false
+ }
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ return p.closed
+}
+```
+
Add to `actEntry`, beside `ops`: `genReaping bool // reapCurrentGen is running for this pending stop; written under a.mu`. Add to `Service`: `unprovenWakes map[string]*unprovenWake // failed wakes whose generation is not proven stopped; under a.mu` (made lazily).
Replace the head of `finishPendingStops`'s loop (lines 2302–2305):
@@ -1992,7 +2068,7 @@ In `Shutdown`, replace lines 2921–2936 (from `var waits []chan struct{}` throu
- [ ] **Step 5: Run the tests to verify they pass**
-Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapWitnessBarrier|TestBarrierKeeps|TestMemberStop|TestSwapWitnessMemberStop|TestSwapWitnessStopProves|TestSwapWitnessShutdownJoins|TestShutdownLeaves' -count=1 -v 2>&1 | tail -20`
+Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapWitnessBarrier|TestBarrierKeeps|TestMemberStop|TestSwapWitnessMemberStop|TestSwapWitnessStopProves|TestAClaimsOnMember|TestSwapWitnessShutdownJoins|TestShutdownLeaves' -count=1 -v 2>&1 | tail -20`
Expected: every subtest `--- PASS`, then `ok`.
- [ ] **Step 6: Run the whole package (the flag-off paths are unchanged)**
@@ -2003,7 +2079,7 @@ Expected: `ok`. Every existing test passes without edits.
- [ ] **Step 7: Commit**
```bash
-git add internal/activator/opset.go internal/activator/activator.go internal/activator/maintenance.go internal/activator/opset_test.go
+git add internal/activator/opset.go internal/activator/activator.go internal/activator/maintenance.go internal/activator/claimwake.go internal/activator/opset_test.go
git commit -m "zdd plan 3 task 2: the entry's operation set, the barrier in stop(), Shutdown joins waking entries"
```
@@ -4375,19 +4451,20 @@ In `doClaimWake`, replace line 108, `policy := startPolicy{MaxStarts: 5, Window:
```go
policy := startPolicy{MaxStarts: 5, Window: 30 * time.Second, Pin: e.pin}
- if e.pin != nil {
- // A claims-on hold's pinned wake: its attempt's checks again before
- // anything is admitted or exec'd (spec §6.3, §7.4), as
+ if e.pin != nil && e.pinCheck != nil {
+ // A claims-on hold's pinned wake: the attempt gate before anything
+ // is admitted, and (policy.Gate, Task 10) again inside the work at
+ // every exec, after the admission and Restore (spec §6.3, §7.4), as
// startPinnedChecked does for a claims-off one.
- a.mu.Lock()
- var perr error
- if e.pinCheck != nil {
- perr = e.pinCheck()
+ gate := func() error {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return e.pinCheck()
}
- a.mu.Unlock()
- if perr != nil {
- return nil, perr
+ if err := gate(); err != nil {
+ return nil, err
}
+ _ = gate // Task 10 hands it to startAdmitted as policy.Gate
}
```
@@ -4442,8 +4519,10 @@ git commit -m "zdd plan 3 task 6: the hold swap, a pinned wake installed by fini
func (b *SplitBackend) StartSidecarGen(ctx context.Context, rt registry.Route, g Gen) (Proc, error)
func (a *Service) startSidecar(ctx context.Context, rt registry.Route, g Gen) (Proc, error)
func (a *Service) replaceSidecar(e *actEntry) // joins an in-flight supervised start, then replaces
- func (a *Service) discardSidecar(sc Proc) // flag off: today's async stop; on: synchronous, before the join closes
- func sidecarJoinBound(gens bool) time.Duration // stop()'s wait on an in-flight sidecar start
+ func (a *Service) discardSidecar(host string, sc Proc) // flag off: today's async stop; on: synchronous, before the join closes; unproven: tracked
+ func (a *Service) trackStraySidecar(host string, sc Proc)
+ func (a *Service) reapStraySidecarsLocked()
+ // Service gains straySidecars map[string][]Proc; with generations on, stop() joins an in-flight sidecar start with no timeout
```
Three rules this task keeps (findings 2, 10 and 11 of the P1 review):
- **Placement.** `startSidecarChildFrom` is plan 2 Task 10's `startSidecarChild` body with one change: the binary. Plan 2's `placeFor(spawnSidecar, 0)`, `spawnInCgroupAt(rt, child, false, …)` and the goroutine's `RemoveChild(rt.Host, child)` stay. Going back to `spawnInCgroup` would put the tier and `memory.oom.group=1` back on the generation parent (spec §7.3) and, with controllers delegated, try to place a process in a parent that must hold none.
@@ -4496,6 +4575,7 @@ import (
"os"
"path/filepath"
"slices"
+ "strings"
"testing"
"time"
@@ -4729,6 +4809,28 @@ func TestSwapStopJoinsTheSidecarAStartLeftBehind(t *testing.T) {
}
}
+// Spec §7.4: a discarded sidecar whose stop is unproven is never forgotten.
+// It stays tracked, the host takes no new wake beside it, and the sweep
+// stops it again until it exits. Logging it and moving on let the next wake
+// run a second sidecar beside a live one.
+func TestAStraySidecarIsNeverForgotten(t *testing.T) {
+ f := newSwapFixture(t)
+ f.a.Bounce(f.host)
+ waitFor(t, "the instance asleep", func() bool { return f.a.entryCount() == 0 })
+ stray := &fakeProc{label: "sidecar", rec: f.b.fakeBackend, stuck: true}
+ f.a.discardSidecar(f.host, stray)
+ if _, err := f.a.beginRequest(context.Background(), testRoute(f.host)); err == nil || !strings.Contains(err.Error(), errWakeUnproven.Error()) {
+ t.Fatalf("a wake beside an unproven sidecar = %v, want refused", err)
+ }
+ stray.exit()
+ f.a.sweepOnce()
+ release, err := f.a.beginRequest(context.Background(), testRoute(f.host))
+ if err != nil {
+ t.Fatalf("the wake once the stray exited: %v", err)
+ }
+ release()
+}
+
// Under plan 2's containment the sidecar keeps its role-sidecar child: no
// limit of its own, and the generation parent keeps memory.oom.group=0 and
// holds no process (spec §7.3, plan 2 D4). Spawning through spawnInCgroup
@@ -4816,7 +4918,7 @@ var _ SidecarGenStarter = (*SplitBackend)(nil)
- [ ] **Step 2: Run the tests to verify they fail**
-Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestLegacySidecar|TestSwapSidecar|TestExecBackendStartSidecarGen|TestStartSidecarGenKeeps' -count=1` (`TestSwapSidecar` covers `TestSwapSidecarNeverRunsBesideTheRetiredGenerationsSidecar`)
+Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestLegacySidecar|TestSwapSidecar|TestSwapStopJoins|TestAStraySidecar|TestExecBackendStartSidecarGen|TestStartSidecarGenKeeps' -count=1` (`TestSwapSidecar` covers `TestSwapSidecarNeverRunsBesideTheRetiredGenerationsSidecar`)
Expected: build failure: `undefined: SidecarGenStarter`, `b.StartSidecarGen undefined`, `undefined: errNestedSidecarGen`.
- [ ] **Step 3: Write the backend side**
@@ -4993,7 +5095,7 @@ func (a *Service) replaceSidecar(e *actEntry) {
sc, err := a.startSidecar(ctx, rt, g)
cancel()
var discard Proc
- defer func() { a.discardSidecar(discard) }() // after the unlock, before done closes
+ defer func() { a.discardSidecar(e.host, discard) }() // after the unlock, before done closes
a.mu.Lock()
defer a.mu.Unlock()
if a.entries[e.host] != e {
@@ -5065,7 +5167,7 @@ and in the same goroutine's two success installs (`e0.sidecar = sc` in the error
```go
var discard Proc
- defer func() { a.discardSidecar(discard) }() // after the unlock, before done closes
+ defer func() { a.discardSidecar(host, discard) }() // after the unlock, before done closes
```
Replace the first branch's `stopDiscardedSidecar(sc)` with `discard = sc`. Replace the second branch with:
@@ -5091,10 +5193,9 @@ Add to `activator.go`, beside `stopDiscardedSidecar`:
// discardSidecar is how a sidecar start that lost its entry is shed. With
// generations off it is today's stopDiscardedSidecar, fire-and-forget. With
// them on it is synchronous and bounded, and the caller runs it before it
-// closes the join (sidecarStartDone) a terminal stop waits on, so the stop
-// never releases the lease while it runs (spec §7.4). An unproven stop is
-// logged; it can only follow a stop that gave up its join.
-func (a *Service) discardSidecar(sc Proc) {
+// closes the join (sidecarStartDone) a terminal stop waits on (spec §7.4).
+// A sidecar whose exit it cannot prove stays tracked (trackStraySidecar).
+func (a *Service) discardSidecar(host string, sc Proc) {
if sc == nil {
return
}
@@ -5105,32 +5206,83 @@ func (a *Service) discardSidecar(sc Proc) {
ctx, cancel := context.WithTimeout(context.Background(), sidecarStopTimeout)
defer cancel()
if err := sc.Stop(ctx); err != nil || !sc.Exited() {
- log.Printf("activator: a discarded SIDECAR did not prove its exit: %v", err)
+ log.Printf("activator: %s: a discarded SIDECAR did not prove its exit (%v); keeping it tracked", host, err)
+ a.trackStraySidecar(host, sc)
+ }
+}
+
+// trackStraySidecar keeps a legacy sidecar whose stop is unproven: it is
+// never forgotten (spec §7.4). The host takes no new wake while one is
+// tracked (errWakeUnproven), and the sweep (reapStraySidecarsLocked) stops
+// it again until it exits.
+func (a *Service) trackStraySidecar(host string, sc Proc) {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ if a.straySidecars == nil {
+ a.straySidecars = make(map[string][]Proc)
+ }
+ a.straySidecars[host] = append(a.straySidecars[host], sc)
+}
+
+// reapStraySidecarsLocked retries each stray sidecar's stop and forgets it
+// once it has exited. The caller holds a.mu.
+func (a *Service) reapStraySidecarsLocked() {
+ for host, procs := range a.straySidecars {
+ var keep []Proc
+ for _, sc := range procs {
+ if sc.Exited() {
+ continue
+ }
+ keep = append(keep, sc)
+ go func(sc Proc) {
+ ctx, cancel := context.WithTimeout(context.Background(), sidecarStopTimeout)
+ _ = sc.Stop(ctx)
+ cancel()
+ }(sc)
+ }
+ if len(keep) == 0 {
+ delete(a.straySidecars, host)
+ } else {
+ a.straySidecars[host] = keep
+ }
}
}
```
-`replaceSidecar` (above) already sheds through `discardSidecar` and installs onto a stopping entry.
+Add to `Service`: `straySidecars map[string][]Proc // legacy sidecars whose stop is unproven, per host; under a.mu`. Call `a.reapStraySidecarsLocked()` beside Task 2's `a.reapUnprovenWakesLocked()` at the top of `finishPendingStops`. In `doWake`, extend Task 2's unproven-wake refusal so a tracked stray also refuses: `_, unproven := a.unprovenWakes[host]; unproven = unproven || len(a.straySidecars[host]) > 0`.
-In `stop()`, the join on a supervised start is bounded by `sidecarStopTimeout` today. With generations on it must cover the whole start and the synchronous discard, so replace `case <-time.After(sidecarStopTimeout):` with:
+In `stop()`'s co-stop, with generations on, a sidecar whose stop is unproven is tracked as well. After main's `outcome.sidecarStop, outcome.sidecarExited = err == nil, e.sidecar.Exited()`, add:
```go
- case <-time.After(sidecarJoinBound(a.gens)):
+ if a.gens && !outcome.sidecarExited {
+ a.trackStraySidecar(e.host, e.sidecar)
+ }
```
-and add:
+`replaceSidecar` (above) already sheds through `discardSidecar` and installs onto a stopping entry.
+
+In `stop()`, the join on a supervised start is bounded by `sidecarStopTimeout` today, and a stop whose join times out goes on to release the lease. With generations on the join has no timeout of its own (P4 review, partial P2 #3): the start goroutine is bounded by its own `DeadlineControl` and its synchronous discard by `sidecarStopTimeout`, and whatever it leaves unproven stays tracked, so the stop never completes past a sidecar it did not prove. Replace
```go
-// sidecarJoinBound is how long stop() waits for an in-flight sidecar start:
-// today's sidecarStopTimeout, or, with generations on, the start's own
-// bound (DeadlineControl) plus a synchronous discard, so the join covers
-// every process that start can leave behind.
-func sidecarJoinBound(gens bool) time.Duration {
- if !gens {
- return sidecarStopTimeout
+ if startDone != nil {
+ select {
+ case <-startDone:
+ case <-time.After(sidecarStopTimeout):
+ }
+ }
+```
+with
+```go
+ if startDone != nil {
+ if a.gens {
+ <-startDone // the start, and every process it left, is accounted for
+ } else {
+ select {
+ case <-startDone:
+ case <-time.After(sidecarStopTimeout):
+ }
+ }
}
- return instancectl.DeadlineControl + sidecarStopTimeout
-}
```
In `sweepOnce`, beside the supervisor (inside the same per-entry block, before its `if e.sidecarWanted && (e.sidecar == nil || e.sidecar.Exited()) …`), add the retry for a retained one:
@@ -6422,6 +6574,8 @@ Claims-on wakes spawn through the claim runtime's provider (`ClaimStart.Build`),
```go
// StartPolicy gains Gen Gen; ClaimStart gains Gen Gen; ClaimRuntime gains Generations bool
// StartPolicy gains Fallback *Gen; HealthBy time.Time (a claimed first wake's fallback and health cap, spec §8.2; plan 5 sets them)
+ // StartPolicy gains Gate func() error: the attempt gate, run in startAdmitted's beforeStart at every exec (a claims-on hold's pinCheck)
+ // startAdmitted records pinFailed and falls back only when classifyStep says stepFailed
// actEntry gains pinFailed *Gen; pinFailReason string (the pinned build a claimed start saw fail before it was ready)
// Service gains: claimWakeSpawn func(ctx context.Context, s ClaimStart, g Gen, before func() error) (*exec.Cmd, *cgroup.Prepared, error) (test seam)
// opSet gains parent *cgroup.Prepared, parentTier cgroup.Limit
@@ -6606,6 +6760,97 @@ func TestStartAdmittedFallsBackToTheGoodBuildOnce(t *testing.T) {
}
}
+// claimedStartFor is a ClaimStart for obs with every hook a no-op, version v.
+func claimedStartFor(obs hostclaim.Observed, work hostwrite.Work, v string) ClaimStart {
+ return ClaimStart{Host: obs.Host, Work: work, Identity: hostwrite.Identity{Config: "c", Release: "r"}, Version: v,
+ Restore: func(context.Context) error { return nil },
+ Materialize: func(context.Context) error { return nil }, Build: func() *exec.Cmd { return nil },
+ Ready: func(context.Context, *exec.Cmd) error { return nil }, Reserve: func(context.Context, int) error { return nil },
+ Failed: func(context.Context, int) error { return nil }, Healthy: func(context.Context, int, *exec.Cmd) error { return nil },
+ Replicate: func(context.Context, string) (Rep, error) { return nil, nil },
+ Publish: func(context.Context, hostclaim.Observed) (hostclaim.Observed, error) { return obs, nil },
+ Retain: func(Proc, Rep, error) {}}
+}
+
+func claimedObs(host string) hostclaim.Observed {
+ return hostclaim.Observed{Host: host, ETag: "one", Claim: &hostclaim.Claim{
+ Holder: &hostclaim.Holder{Location: "instances/" + host + "/inc", Session: 1, Generation: "one"},
+ Op: hostclaim.Op{Kind: hostclaim.KindServing, Exec: "box"},
+ }}
+}
+
+// Spec §6.3, §7.4: a claims-on hold's pinned wake runs its attempt gate
+// inside the work at the exec, after the admission and Restore. Here the
+// attempt goes stale during Restore: nothing execs. Checked only before the
+// admission, the superseded hold build ran.
+func TestAClaimsOnHoldRunsTheGateAtTheExec(t *testing.T) {
+ obs := claimedObs("x.example")
+ work, _ := (hostwrite.Config{}).Select(context.Background(), obs.Host, "spawn")
+ var stale, spawned bool
+ a := &Service{cfg: Config{HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box",
+ Read: func(context.Context, string) (hostclaim.Observed, error) { return obs, nil },
+ Prepare: func(context.Context, registry.Route, hostclaim.Observed, startPolicy) (ClaimStart, error) {
+ s := claimedStartFor(obs, work, "v2")
+ s.Restore = func(context.Context) error { stale = true; return nil } // a newer attempt lands
+ return s, nil
+ }}}}
+ a.claimWakeSpawn = func(_ context.Context, _ ClaimStart, _ Gen, before func() error) (*exec.Cmd, *cgroup.Prepared, error) {
+ if err := before(); err != nil {
+ return nil, nil, err
+ }
+ spawned = true
+ return nil, nil, errors.New("test: stop after the exec")
+ }
+ policy := startPolicy{MaxStarts: 1, Window: time.Second, Pin: &Gen{Build: "v2"},
+ Gate: func() error {
+ if stale {
+ return errSwapSuperseded
+ }
+ return nil
+ }}
+ err := a.startAdmitted(context.Background(), &actEntry{host: obs.Host}, obs, policy)
+ if spawned {
+ t.Fatal("the hold build was exec'd after its attempt went stale during Restore")
+ }
+ if !errors.Is(err, errSwapSuperseded) {
+ t.Fatalf("startAdmitted = %v, want errSwapSuperseded", err)
+ }
+}
+
+// Spec §6.4, §7.4: a claimed first wake that its lifecycle cancels (here
+// mid health window) is no failure of the build: no pinFailed, no fallback.
+// startAdmitted recorded every not-ready pinned start as a failed build.
+func TestAClaimedWakeCancelledByItsLifecycleRecordsNoFailure(t *testing.T) {
+ obs := claimedObs("x.example")
+ work, _ := (hostwrite.Config{}).Select(context.Background(), obs.Host, "spawn")
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ prepares := 0
+ a := &Service{cfg: Config{HostWrites: hostwrite.Config{Enabled: true}, Claims: &ClaimRuntime{Box: "box",
+ Read: func(context.Context, string) (hostclaim.Observed, error) { return obs, nil },
+ Prepare: func(_ context.Context, _ registry.Route, _ hostclaim.Observed, p startPolicy) (ClaimStart, error) {
+ prepares++
+ s := claimedStartFor(obs, work, p.Pin.Build)
+ s.Ready = func(rctx context.Context, _ *exec.Cmd) error { cancel(); return rctx.Err() } // Shutdown, maintenance
+ return s, nil
+ }}}}
+ a.claimWakeSpawn = func(_ context.Context, _ ClaimStart, _ Gen, before func() error) (*exec.Cmd, *cgroup.Prepared, error) {
+ if err := before(); err != nil {
+ return nil, nil, err
+ }
+ return exec.Command("true"), nil, nil
+ }
+ e := &actEntry{host: obs.Host}
+ policy := startPolicy{MaxStarts: 2, Window: time.Second, Pin: &Gen{Build: "v2"}, Fallback: &Gen{Build: "v1"}}
+ _ = a.startAdmitted(ctx, e, obs, policy)
+ if e.pinFailed != nil {
+ t.Fatalf("pinFailed = %+v: a cancelled wake was recorded as a failed build", e.pinFailed)
+ }
+ if prepares != 1 {
+ t.Fatalf("Prepare ran %d times: a cancelled wake fell back", prepares)
+ }
+}
+
// Spec §6.3, §7.4: a claims-on successor's Read and Prepare come after the
// attempt's last check at Step 3a and may take time. The attempt gate runs
// again after them (and inside the work at the exec, as spawnClaimedGen's
@@ -6647,7 +6892,7 @@ Add `"sync"` and `"github.com/carlosframework/platform/internal/cgroup"` to the
- [ ] **Step 2: Run the tests to verify they fail**
-Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestClaimsOnSwapNeedsGenerations|TestStartAdmittedRefusesAGenerationMismatch|TestClaimGenAt|TestStartAdmittedFallsBack|TestClaimsOnSuccessorReChecks' -count=1`
+Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestClaimsOnSwapNeedsGenerations|TestStartAdmittedRefusesAGenerationMismatch|TestClaimGenAt|TestStartAdmittedFallsBack|TestClaimsOnSuccessorReChecks|TestAClaimsOnHoldRunsTheGate|TestAClaimedWakeCancelled' -count=1`
Expected: build failure: `undefined: errClaimsNoGenerations`, `unknown field Gen in struct literal of type ClaimStart`, `a.claimGenAt undefined`, `unknown field Fallback in struct literal of type StartPolicy`.
- [ ] **Step 3: Extend the claim types and `startAdmitted`**
@@ -6669,6 +6914,29 @@ Expected: build failure: `undefined: errClaimsNoGenerations`, `unknown field Gen
// wake's health check ends at the earlier of Window and HealthBy (spec
// §8.2: the waiters' deadline minus 25 s). Fallback's start is not capped.
HealthBy time.Time
+ // Gate, when set, is the attempt gate (attemptCurrentLocked, taking a.mu
+ // itself) for a claims-on hold's pinned wake. startAdmitted runs it
+ // inside the selected work immediately before every exec, after the
+ // admission and Restore (spec §6.3, §7.4).
+ Gate func() error
+```
+
+In `startAdmitted`'s attempt loop, replace main's
+
+```go
+ beforeStart := func() error { return budget.start(ctx, s.OpenProbe) }
+```
+with
+```go
+ beforeStart := func() error {
+ if policy.Gate != nil {
+ // The attempt gate at the exec (Task 6's pinCheck).
+ if err := policy.Gate(); err != nil {
+ return err
+ }
+ }
+ return budget.start(ctx, s.OpenProbe)
+ }
```
Add to `actEntry` (`activator.go`, after Task 6's `holdNext`):
@@ -6709,7 +6977,7 @@ Add to `ClaimRuntime`:
Generations bool
```
-In `doClaimWake`, after the `policy :=` line Task 6 wrote, add:
+In `doClaimWake`'s pinned block (Task 6), replace `_ = gate // Task 10 hands it to startAdmitted as policy.Gate` with `policy.Gate = gate`. After the block, add:
```go
if a.gens && r.Generations {
@@ -6790,7 +7058,12 @@ with:
return errors.Join(startErr, err)
}
last = startErr
- if policy.Pin != nil && !ready && cmd != nil {
+ // THE outcome classifier (Task 4): a start the wake's lifecycle ended
+ // (maintenance, Shutdown, a stop) or the attempt gate refused is no
+ // failure of the build, records nothing and falls back to nothing
+ // (spec §6.4, §7.4).
+ buildFailed := classifyStep(ctx, startErr) == stepFailed
+ if policy.Pin != nil && !ready && cmd != nil && buildFailed {
// The pinned build ran and failed before it was ready. Recorded
// for plan 5's events (a failed first wake, spec §8.2); a later
// ready start of the same pin clears it below.
@@ -6801,7 +7074,7 @@ with:
}
a.mu.Unlock()
}
- if policy.Fallback != nil && !ready && cmd != nil && ctx.Err() == nil && attempt+1 < policy.MaxStarts {
+ if policy.Fallback != nil && !ready && cmd != nil && buildFailed && attempt+1 < policy.MaxStarts {
// A first wake onto a build this instance has never served
// (zero-downtime spec §8.2). p.Stop above killed the failed
// child outright. The good build gets what remains, with no
@@ -7171,12 +7444,21 @@ git commit -m "zdd plan 3 task 10: claims-on spawns get generation sockets, hand
Plan 1 gives workers (native sidecar members) no `Gen`. With generations on, this task gives every worker child a numbered handle and makes a supervised respawn a member of the operation set. Then the barrier and plan 5's generations file see it like any other generation. Workers still bind nothing (`Socket` stays empty) and keep stop-then-start (§7.9). Recycling onto the captured serving build is plan 4's.
**Files:**
-- Modify: `internal/activator/activator.go`: `wake()`'s awake transition; `respawnWorkerLocked`, lines 1868–1976
+- Modify: `internal/activator/activator.go`: `wake()`'s awake transition; `respawnWorkerLocked`, lines 1868–1976; `internal/activator/opset.go` (`genChildEmpty`, `hostKiller`); `internal/activator/genbackend.go` (`KillHost`); `internal/activator/generations.go` (`Gen.Worker`)
- Test: `internal/activator/worker_gen_test.go`
**Interfaces:**
-- Consumes: Task 2's set, C1 and C3.
-- Produces: no new names. A worker's `e.gen` is `Gen{N, Build}` with generations on, and a respawn in flight is a `roleSpawning` member.
+- Consumes: Task 2's set, C1 and C3; `cgroup.Manager.OpenCleanup(host)` and `(*Cleanup).KillAndEmpty` (on `main`).
+- Produces:
+ ```go
+ // Gen gains Worker bool: a socketless generation, which runs in its host's own cgroup (plan 2 D4), not in g<n>
+ type hostKiller interface{ KillHost(ctx context.Context, rt registry.Route) error }
+ func (b *ExecBackend) KillHost(ctx context.Context, rt registry.Route) error
+ func (b *SplitBackend) KillHost(ctx context.Context, rt registry.Route) error
+ ```
+ A worker's `e.gen` is `Gen{N, Build, Worker: true}` with generations on, and a respawn in flight is a `roleSpawning` member.
+
+ **A worker's termination proof is its host cgroup** (spec §7.4, §7.9; P4 review finding 1). Workers keep one cgroup per host (plan 2 D4), so `KillGen` on a `g<n>` they never ran in proves nothing, and `childProc.Stop` signals only the leader. `genChildEmpty` therefore proves a `Worker` generation by `KillHost`: `KillAndEmpty` on the host cgroup, which on a worker host holds that worker alone. Every worker path goes through `genTerminated`: a terminal stop, a respawn retiring the old child before it spawns the new, a respawn shedding a child it raced into existence, and plan 5's `killFailed` before a fallback. When the proof fails, ownership is kept: the stop stays pending with the lease, a respawn spawns nothing, and a shed child stays in the operation set for the reaper.
- [ ] **Step 1: Write the failing test**
@@ -7186,8 +7468,13 @@ Create `internal/activator/worker_gen_test.go`:
package activator
import (
+ "context"
+ "errors"
+ "sync"
"testing"
"time"
+
+ "github.com/carlosframework/platform/internal/registry"
)
func TestWorkerGenerations(t *testing.T) {
@@ -7218,17 +7505,96 @@ func TestWorkerGenerations(t *testing.T) {
waitFor(t, "the respawn to finish", func() bool { return b.workerStarts() == 2 })
waitFor(t, "the member to leave", func() bool { _, m := a.genStateForTest(rt.Host); return len(m) == 0 })
if on {
- if second, _ := a.genStateForTest(rt.Host); second.N <= first.N {
- t.Fatalf("respawned worker generation %d, want above %d", second.N, first.N)
+ if second, _ := a.genStateForTest(rt.Host); second.N <= first.N || !second.Worker {
+ t.Fatalf("respawned worker generation %+v, want a Worker above %d", second, first.N)
}
}
}
}
+
+// hostKillBackend is fakeBackend plus KillHost, failing while bad.
+type hostKillBackend struct {
+ *fakeBackend
+ hmu sync.Mutex
+ bad bool
+ kills int
+}
+
+func (h *hostKillBackend) KillHost(context.Context, registry.Route) error {
+ h.hmu.Lock()
+ defer h.hmu.Unlock()
+ h.kills++
+ if h.bad {
+ return errors.New("the host cgroup still holds a process")
+ }
+ return nil
+}
+
+func (h *hostKillBackend) heal() {
+ h.hmu.Lock()
+ h.bad = false
+ h.hmu.Unlock()
+}
+
+// Spec §7.4, §7.9: a worker runs in its host cgroup (plan 2 D4), so that is
+// what proves it terminated, not a g<n> it never ran in (whose absence
+// KillGen counts as empty). While the host cgroup still holds a process
+// (a descendant the worker forked), a terminal stop keeps the lease and a
+// respawn execs nothing beside it.
+func TestWorkerTerminationProvesItsHostCgroup(t *testing.T) {
+ t.Run("a terminal stop keeps the lease", func(t *testing.T) {
+ a, b, _, _ := testActivator(t, time.Hour)
+ a.gens = true
+ hk := &hostKillBackend{fakeBackend: b, bad: true}
+ a.backend = hk
+ rt := testWorkerRoute("bot.example.com")
+ if err := a.WakeWorker(rt); err != nil {
+ t.Fatal(err)
+ }
+ rep := b.lastRep()
+ a.Bounce(rt.Host)
+ waitFor(t, "the stop to pend", func() bool {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ e := a.entries[rt.Host]
+ return e != nil && e.pendingStop
+ })
+ time.Sleep(50 * time.Millisecond)
+ if rep.isClosed() {
+ t.Fatal("the lease was released while the worker's host cgroup still held a process")
+ }
+ hk.heal()
+ a.sweepOnce()
+ waitFor(t, "the lease release", rep.isClosed)
+ })
+ t.Run("a respawn execs nothing beside the old child", func(t *testing.T) {
+ old := sidecarSettle
+ sidecarSettle = 20 * time.Millisecond
+ t.Cleanup(func() { sidecarSettle = old })
+ a, b, _, _ := testActivator(t, time.Hour)
+ a.gens = true
+ hk := &hostKillBackend{fakeBackend: b}
+ a.backend = hk
+ rt := testWorkerRoute("bot2.example.com")
+ if err := a.WakeWorker(rt); err != nil {
+ t.Fatal(err)
+ }
+ hk.hmu.Lock()
+ hk.bad = true
+ hk.hmu.Unlock()
+ b.lastWorker().crash()
+ a.sweepOnce() // the respawn retires the crashed child, and its proof fails
+ waitFor(t, "the respawn to finish", func() bool { return !a.entryRespawning(rt.Host) })
+ if got := b.workerStarts(); got != 1 {
+ t.Fatalf("worker starts = %d: a respawn exec'd beside an unproven old child", got)
+ }
+ })
+}
```
- [ ] **Step 2: Run the test to verify it fails**
-Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run TestWorkerGenerations -count=1`
+Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestWorkerGenerations|TestWorkerTermination' -count=1`
Expected: FAIL: `gens true: worker generation {N:0 ...}`.
- [ ] **Step 3: Write the code**
@@ -7239,11 +7605,92 @@ In `wake()`, after plan 1's `if e.gen.N == 0 && !e.worker { ... }` block, add:
if a.gens && e.worker && e.gen.N == 0 {
// A worker binds nothing, but it is still a generation: numbered,
// with the build it runs, so the barrier and the generations file
- // see it (zero-downtime plan 3, Task 11).
- e.gen = Gen{N: nextGen(), Build: w.spawnVersion}
+ // see it (zero-downtime plan 3, Task 11). Worker: its termination
+ // is proven on its host cgroup.
+ e.gen = Gen{N: nextGen(), Build: w.spawnVersion, Worker: true}
}
```
+Add the field to plan 1's `Gen`, after Task 1's `Tier`:
+
+```go
+ // Worker marks a socketless generation (a worker or native sidecar
+ // member). It runs in its host's own cgroup, not in a g<n> child (plan 2
+ // D4), so its termination is proven there (genChildEmpty, KillHost).
+ Worker bool
+```
+
+In `opset.go`'s `genChildEmpty`, insert directly after its `claimProcess` branch:
+
+```go
+ if m.gen.Worker {
+ // A worker runs in its host cgroup (plan 2 D4): that cgroup, not a
+ // g<n> it never ran in, is what must be empty (spec §7.4, §7.9).
+ hk, ok := a.backend.(hostKiller)
+ if !ok {
+ return true // a backend with no host cgroup: the leader's exit is all there is
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), instancectl.DeadlineControl)
+ defer cancel()
+ if err := hk.KillHost(ctx, rt); err != nil {
+ log.Printf("activator: %s: worker generation %d's host cgroup is not proven empty: %v", rt.Host, m.gen.N, err)
+ return false
+ }
+ return true
+ }
+```
+
+and add to `opset.go`:
+
+```go
+// hostKiller is the exec backend's host-cgroup kill: cgroup.kill on host's
+// own cgroup, then wait until it is empty. It proves a worker generation
+// terminated (plan 2 D4 keeps workers there).
+type hostKiller interface {
+ KillHost(ctx context.Context, rt registry.Route) error
+}
+```
+
+Add to `genbackend.go`:
+
+```go
+// KillHost kills whatever is left in rt's own host cgroup and returns once
+// it is empty: a worker's termination proof (zero-downtime spec §7.4, §7.9).
+// A nested or unit-backed worker is a systemd unit, whose stop already
+// takes its whole cgroup, so there is nothing more to prove here. An absent
+// cgroup is empty. No cgroup delegation at all is an unproven kill.
+func (b *ExecBackend) KillHost(ctx context.Context, rt registry.Route) error {
+ if p := b.pathsFor(rt); p.layout == LayoutNested {
+ return nil
+ }
+ if b.Cgroups == nil {
+ return cgroup.ErrNoDelegation
+ }
+ c, err := b.Cgroups.OpenCleanup(rt.Host)
+ if errors.Is(err, cgroup.ErrNoCgroup) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if err := c.KillAndEmpty(ctx); err != nil {
+ _ = c.Close()
+ return err
+ }
+ return c.Close()
+}
+
+func (b *SplitBackend) KillHost(ctx context.Context, rt registry.Route) error {
+ if rt.Backing == registry.BackingUnit {
+ return nil // systemd owns the unit tenant's tree
+ }
+ if k, ok := b.Exec.(hostKiller); ok {
+ return k.KillHost(ctx, rt)
+ }
+ return nil
+}
+```
+
In `respawnWorkerLocked`, replace its first three lines:
```go
@@ -7262,8 +7709,9 @@ with
// Task 11).
spawnParent, cancelSpawn := context.WithCancel(a.lifeCtx)
var m *genMember
+ oldGen := e.gen // the retiring child's generation, for its proof
if a.gens {
- m = &genMember{gen: Gen{N: nextGen()}, role: roleSpawning, cancel: cancelSpawn, done: make(chan struct{})}
+ m = &genMember{gen: Gen{N: nextGen(), Worker: true}, role: roleSpawning, cancel: cancelSpawn, done: make(chan struct{})}
a.addMemberLocked(e, m)
}
```
@@ -7272,11 +7720,63 @@ Inside the goroutine, after `defer close(done)`, add:
```go
defer cancelSpawn()
+ shed := false // a shed child went to dropMember, which leaves the set itself
if m != nil {
- defer a.leaveSet(e, m) // runs before close(done): defers are LIFO
+ defer func() { // runs before close(done): defers are LIFO
+ if !shed {
+ a.leaveSet(e, m)
+ }
+ }()
}
```
+Directly after the retire block (`if stopOld != nil { … }`) and before the "Still wanted?" `a.mu.Lock()`, add:
+
+```go
+ // The retired child must be proven gone, its host cgroup included,
+ // before anything new execs beside its descendants (zero-downtime
+ // spec §7.4, §7.9). Unproven: nothing spawns; the entry keeps its
+ // lease, and the sweep's crash path stops it through genTerminated.
+ retired := true
+ if a.gens && stopOld != nil {
+ retired = a.genTerminated(rt, oldGen, stopOld)
+ }
+```
+
+and directly after that block's `a.mu.Unlock()`, add `wanted = wanted && retired`.
+
+In the `case !wanted || !live:` arm, replace main's shed of `proc`
+
+```go
+ if proc != nil {
+ // Spawned into a window that closed under us: shed it rather
+ // than leave a child nothing owns.
+ shedCtx, shedCancel := context.WithTimeout(context.Background(), sidecarStopTimeout)
+ _ = proc.Stop(shedCtx)
+ shedCancel()
+ }
+```
+with
+```go
+ if proc != nil {
+ // Spawned into a window that closed under us: shed it rather
+ // than leave a child nothing owns. With generations on it is
+ // the member's process: dropMember stops it and proves its
+ // host cgroup empty, or keeps it in the set for the reaper.
+ if m != nil {
+ a.mu.Lock()
+ m.proc = proc
+ a.mu.Unlock()
+ shed = true
+ a.dropMember(e, m)
+ } else {
+ shedCtx, shedCancel := context.WithTimeout(context.Background(), sidecarStopTimeout)
+ _ = proc.Stop(shedCtx)
+ shedCancel()
+ }
+ }
+```
+
Then replace the spawn context
```go
@@ -7291,7 +7791,7 @@ In the success arm (`default:`), after the line that sets `e.spawnVersion` (`e.s
```go
if m != nil {
- e.gen = Gen{N: m.gen.N, Build: e.spawnVersion}
+ e.gen = Gen{N: m.gen.N, Build: e.spawnVersion, Worker: true}
}
```
@@ -7303,7 +7803,7 @@ Expected: `ok`. The existing worker tests are the flag-off proof.
- [ ] **Step 5: Commit**
```bash
-git add internal/activator/activator.go internal/activator/worker_gen_test.go
+git add internal/activator/activator.go internal/activator/opset.go internal/activator/genbackend.go internal/activator/generations.go internal/activator/worker_gen_test.go
git commit -m "zdd plan 3 task 11: worker children are numbered generations; a respawn joins the operation set"
```
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 4b22c3a10..be62862ed 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
@@ -3391,6 +3391,45 @@ func TestConfigDeliveredMidSwapIsAckedOnlyWhenActive(t *testing.T) {
waitFor(t, "the ack", func() bool { return len(acks) == 1 })
}
+// Spec §8.1: a successful config attempt's activation is published before
+// its slot frees, and with no registry read in front of it. Published after
+// the slot and the read, a newer config attempt could complete first and be
+// overwritten (active went back to the older revision), and an unreadable
+// row lost the activation, leaving delivered and active apart for good.
+func TestConfigActivationIsPublishedBeforeTheSlotFrees(t *testing.T) {
+ h := newHarness(t, "a")
+ h.pass()
+ var acks, freed atomic.Int32
+ ack := h.m.DeferAck(func(appconfig.ConfigAck) {
+ acks.Add(1)
+ s := h.m.schedulerOf("a")
+ s.mu.Lock()
+ if hs := s.q.hosts["a"]; hs != nil && hs.running == nil {
+ freed.Add(1)
+ }
+ s.mu.Unlock()
+ })
+ var swapped atomic.Bool
+ h.act.outcome = func(activator.Attempt) activator.SwapOutcome {
+ swapped.Store(true)
+ return activator.SwapDone
+ }
+ h.rows.mu.Lock()
+ h.rows.getErr = func(string) error {
+ if swapped.Load() {
+ return errors.New("registry: database is locked")
+ }
+ return nil
+ }
+ h.rows.mu.Unlock()
+ h.m.ConfigDelivered("a", "sha256:c1")
+ ack(appconfig.ConfigAck{Host: "a"})
+ waitFor(t, "the config activated and acknowledged", func() bool { return acks.Load() == 1 })
+ if n := freed.Load(); n != 0 {
+ t.Fatal("the activation was published after the attempt's slot was freed")
+ }
+}
+
func TestAFailedConfigAttemptRetriesWithBackoff(t *testing.T) {
h := newHarness(t, "a")
h.act.outcome = func(a activator.Attempt) activator.SwapOutcome {
@@ -4409,6 +4448,11 @@ func (s *scheduler) done(d dispatch, res activator.SwapResult, mode activator.Sw
// one. writeGood also requires the generation to be the one the
// activator serves (acknowledged).
m.served(d.ID.Host, res.Gen)
+ // The config the attempt started with is active, published under the
+ // same rule (spec §8.1): before the slot frees, so a newer config
+ // attempt cannot complete first and be overwritten here, and with no
+ // registry read in front of it, so an unreadable row loses nothing.
+ m.configActivated(d.ID.Host, d.ID.ConfigRev)
}
s.mu.Lock()
transition := s.q.finish(d, res.Outcome)
@@ -4443,7 +4487,6 @@ func (s *scheduler) done(d dispatch, res activator.SwapResult, mode activator.Sw
}
switch res.Outcome {
case activator.SwapDone:
- m.configActivated(d.ID.Host, d.ID.ConfigRev)
m.mu.Lock()
if m.retried[d.ID.Host] == d.ID.Build {
delete(m.retried, d.ID.Host)
@@ -5057,6 +5100,48 @@ func TestAPlannedOneShotWorkerThatExitsCleanlyIsReady(t *testing.T) {
}
}
+// stagedHostKill is plan 4's stagedFake plus plan 3's KillHost, failing
+// while bad: a worker host cgroup that still holds a process.
+type stagedHostKill struct {
+ *stagedFake
+ bad atomic.Bool
+}
+
+func (s *stagedHostKill) KillHost(context.Context, registry.Route) error {
+ if s.bad.Load() {
+ return errors.New("the host cgroup still holds a process")
+ }
+ return nil
+}
+
+// Spec §7.4, §7.9: a failed socketless first wake falls back to the good
+// build only once the failed worker's host cgroup is proven empty (plan 3's
+// genTerminated on a Worker generation). Its leader's exit alone let the
+// good build start beside a descendant of the failed one.
+func TestAFailedWorkerFirstWakeFallsBackOnlyOnceItsCgroupIsEmpty(t *testing.T) {
+ shrinkFirstWake(t, 55*time.Second, 25*time.Second, 300*time.Millisecond)
+ a, b, _, _, _ := stagedActivator(t)
+ a.gens = true
+ hk := &stagedHostKill{stagedFake: b}
+ hk.bad.Store(true)
+ a.backend = hk
+ a.OnGen = (&genLog{}).add
+ dir := t.TempDir()
+ good, next := stagedID(t, dir, "v1"), stagedID(t, dir, "v2")
+ a.Planner = func(context.Context, registry.Route) WakePlan {
+ return WakePlan{Boot: next, Mode: ModeOverlap, FirstWake: true, Fallback: &good}
+ }
+ go func() {
+ waitFor(t, "the v2 child", func() bool { return len(b.calls()) == 1 })
+ time.Sleep(50 * time.Millisecond)
+ b.lastWorker().crash()
+ }()
+ _ = a.WakeWorker(alwaysOnWorkerRoute("bot.x.internal"))
+ if calls := b.calls(); len(calls) != 1 {
+ t.Fatalf("spawns %v: the good build started beside the failed worker's unproven host cgroup", calls)
+ }
+}
+
// Spec §6.4, §7.4: a wake cancelled by its lifecycle (here Shutdown, as
// maintenance or a stop would) is no failure of the build: no failure
// event, no fallback, so no halted cohort and no hold-down. spawnPlanned
@@ -5219,7 +5304,7 @@ func TestBounceForNamesTheCause(t *testing.T) {
- [ ] **Step 2: Run them to see them fail**
-Run: `mkdir -p /tmp/k && TMPDIR=/tmp/k go test ./internal/activator/ -run 'TestFirstWake|TestAHoldFirstWake|TestAnUnprovenKill|TestAnExitInside|TestAnIntentionalStop|TestAFirstWakeThat|TestARefusedPlan|TestAClaimedFirstWake|TestAPlannedFirstWakeOnTheRealExecBackend|TestAPlannedOneShotWorker|TestAFirstWakeIsObserved|TestAPlannedWorkerFirstWake|TestACancelledFirstWake|TestWithoutAPlanner|TestDriftFeeds|TestBounceFor' -count=1`
+Run: `mkdir -p /tmp/k && TMPDIR=/tmp/k go test ./internal/activator/ -run 'TestFirstWake|TestAHoldFirstWake|TestAnUnprovenKill|TestAnExitInside|TestAnIntentionalStop|TestAFirstWakeThat|TestARefusedPlan|TestAClaimedFirstWake|TestAPlannedFirstWakeOnTheRealExecBackend|TestAPlannedOneShotWorker|TestAFirstWakeIsObserved|TestAPlannedWorkerFirstWake|TestACancelledFirstWake|TestAFailedWorkerFirstWakeFallsBack|TestWithoutAPlanner|TestDriftFeeds|TestBounceFor' -count=1`
Expected: FAIL, `undefined: WakePlan`.
- [ ] **Step 3: Write the plan types and helpers**
@@ -5256,6 +5341,11 @@ type WakePlan struct {
Fallback *release.StagedIdentity
// Refuse, when set, fails the wake before the lease with this reason.
Refuse string
+ // FailedUnder is the PromotedAt of the pointer Boot was promoted under,
+ // read by the planner before the wake runs, so a failed first wake is
+ // published with no blocking read (spec §8.1), as dispatch.FailedUnder
+ // is for an attempt. Zero: the failure's own time stands in.
+ FailedUnder time.Time
}
func (p WakePlan) planned() bool { return p.Boot.StagedPath != "" }
@@ -5288,6 +5378,9 @@ type GenEvent struct {
FirstWake bool
Reason string
At time.Time
+ // FailedUnder is the plan's FailedUnder (failed, fellback): the
+ // failing pointer's PromotedAt, read before the wake ran.
+ FailedUnder time.Time
}
// The first-wake budget (spec §8.2). Vars so tests can shrink them.
@@ -5360,7 +5453,12 @@ func (a *Service) killFailed(rt registry.Route, g Gen, p Proc, worker bool) erro
if err := p.Stop(ctx); err != nil {
return err
}
- if !p.Exited() {
+ // A worker runs in its host cgroup (plan 2 D4): its leader's exit is not
+ // the end of a descendant it forked. THE termination proof (plan 3's
+ // genTerminated, with a Worker generation) empties and checks the host
+ // cgroup, so the good build never starts beside the failed one (spec
+ // §7.4, §7.9).
+ if !a.genTerminated(rt, Gen{N: g.N, Build: g.Build, Worker: true}, p) {
return ErrStillRunning
}
return nil
@@ -5485,10 +5583,10 @@ func (a *Service) afterWake(e *actEntry, w *wokenInstance, err error) {
switch {
case err != nil:
if w.failed != nil {
- a.emitGen(GenEvent{Kind: GenFailed, Route: e.route, Failed: *w.failed, Mode: plan.Mode, FirstWake: true, Reason: w.failReason, At: now})
+ a.emitGen(GenEvent{Kind: GenFailed, Route: e.route, Failed: *w.failed, Mode: plan.Mode, FirstWake: true, Reason: w.failReason, At: now, FailedUnder: plan.FailedUnder})
}
case w.fellBack:
- a.emitGen(GenEvent{Kind: GenFellBack, Route: e.route, Gen: w.planned, Failed: *w.failed, Mode: plan.Mode, FirstWake: true, Reason: w.failReason, At: now})
+ a.emitGen(GenEvent{Kind: GenFellBack, Route: e.route, Gen: w.planned, Failed: *w.failed, Mode: plan.Mode, FirstWake: true, Reason: w.failReason, At: now, FailedUnder: plan.FailedUnder})
default:
a.emitGen(GenEvent{Kind: GenHealthy, Route: e.route, Gen: w.planned, Mode: plan.Mode, FirstWake: plan.FirstWake, At: now})
switch {
@@ -5557,7 +5655,7 @@ func (a *Service) watchFirstWake(e *actEntry, g Gen, plan WakePlan) {
a.emitGen(GenEvent{Kind: GenSettled, Route: rt, Gen: g, Mode: plan.Mode, FirstWake: true, At: time.Now()})
case exited:
a.emitGen(GenEvent{Kind: GenFailed, Route: rt, Failed: plan.Boot, Mode: plan.Mode, FirstWake: true,
- Reason: "exited within " + firstWakeSettle.String() + " of becoming healthy", At: time.Now()})
+ Reason: "exited within " + firstWakeSettle.String() + " of becoming healthy", At: time.Now(), FailedUnder: plan.FailedUnder})
default: // an intentional stop cancelled it
cancelled(rt)
}
@@ -6087,6 +6185,36 @@ func TestASleepersConfigNeedsNoAttempt(t *testing.T) {
}
}
+// Spec §8.1: a failed first wake is published with no blocking read in
+// between. The failing pointer's PromotedAt is read when the wake is
+// planned and travels on the event. buildFailed read it again after the
+// failure, before the record, leaving every sibling's wake free to plan the
+// failed build meanwhile.
+func TestAFailedFirstWakeIsRecordedWithNoPointerRead(t *testing.T) {
+ h := newHarness(t, "a")
+ h.stage("v2")
+ h.install("v2", "a")
+ promoted := t0.Add(-time.Hour)
+ h.pointer("stable", "v2", promoted)
+ p := h.m.Plan(context.Background(), h.rows.get("a"))
+ if !p.FirstWake || !p.FailedUnder.Equal(promoted) {
+ t.Fatalf("plan %+v: want a first wake carrying the pointer's PromotedAt", p)
+ }
+ var reads atomic.Int32
+ h.mu.Lock()
+ h.pointerHook = func() { reads.Add(1) }
+ h.mu.Unlock()
+ h.m.OnGen(activator.GenEvent{Kind: activator.GenFailed, Route: h.rows.get("a"), FirstWake: true, Mode: activator.ModeOverlap,
+ Failed: p.Boot, Reason: "GET / answered 500", FailedUnder: p.FailedUnder})
+ if n := reads.Load(); n != 0 {
+ t.Fatalf("%d pointer read(s) between the failure and its record", n)
+ }
+ rec, ok := h.book.Lookup("bab", "blog", "v2")
+ if !ok || !rec.PromotedAt.Equal(promoted) {
+ t.Fatalf("record %+v %v: want one carrying the planned PromotedAt", rec, ok)
+ }
+}
+
// Spec §6.4, §8.4: a cold managed host (nothing installed yet) has its build
// installed and stamped before the wake is planned, so the plan boots that
// identity, as a first wake, and respects its failure records. An empty
@@ -6153,11 +6281,11 @@ func TestAForeignRowsFailedHoldIsHeldDown(t *testing.T) {
}
```
-Add `"time"` and `"github.com/carlosframework/platform/internal/release"` to that file's imports.
+Add `"sync/atomic"`, `"time"` and `"github.com/carlosframework/platform/internal/release"` to that file's imports.
- [ ] **Step 2: Run them to see them fail**
-Run: `go test ./internal/rollout/ -run 'TestDecideWake|TestPlanBoots|TestAFellBack|TestOnlyASettled|TestAStaleSuccess|TestAFailedHold|TestAWakeActivates|TestASleepersConfig|TestAForeignRowsFailedHold|TestAColdManagedHost' -count=1`
+Run: `go test ./internal/rollout/ -run 'TestDecideWake|TestPlanBoots|TestAFellBack|TestOnlyASettled|TestAStaleSuccess|TestAFailedHold|TestAFailedFirstWakeIsRecorded|TestAWakeActivates|TestASleepersConfig|TestAForeignRowsFailedHold|TestAColdManagedHost' -count=1`
Expected: FAIL, `undefined: decideWake`.
- [ ] **Step 3: Write the planner and the outcome handler**
@@ -6295,6 +6423,11 @@ func (m *Manager) Plan(ctx context.Context, rt registry.Route) activator.WakePla
if err != nil {
m.stagingRefused(rt, rt.Version, err)
}
+ if plan.FirstWake && plan.Boot.Version != "" {
+ // Read now, before the wake runs, so a failure of the build is
+ // published with no blocking read (buildFailed, spec §8.1).
+ plan.FailedUnder = m.failedUnder(rt, plan.Boot.Version)
+ }
return plan
}
@@ -6351,9 +6484,14 @@ func (m *Manager) wakeConfigActive(host string) {
// buildFailed is a failed first wake's cohort-failure transition (spec
// §8.1, §8.2 step 4).
func (m *Manager) buildFailed(rt registry.Route, ev activator.GenEvent) {
+ // No blocking read before the record (spec §8.1): the failing pointer's
+ // PromotedAt was read when the wake was planned (WakePlan.FailedUnder).
rec := FailureRecord{Account: release.RouteAccount(rt), App: rt.App, Build: ev.Failed.Version, Host: rt.Host,
Cause: string(activator.CauseRelease), Mode: string(ev.Mode), Reason: ev.Reason,
- PromotedAt: m.failedUnder(rt, ev.Failed.Version), At: m.d.Now()}
+ PromotedAt: ev.FailedUnder, At: m.d.Now()}
+ if rec.PromotedAt.IsZero() {
+ rec.PromotedAt = rec.At
+ }
if ev.Mode == activator.ModeHold {
rec.HeldDown = []string{rt.Host}
}
@@ -6400,6 +6538,8 @@ git commit -m "zdd-5 task 10: the wake planner and planned-wake outcomes"
// actEntry gains: attemptBuilds []string (the builds a socketless attempt in flight may exec, for the generations file, Task 12)
// retireTook time.Duration (how long the last respawn took to retire its old child)
// attemptGate func() error; attemptRestore *release.StagedIdentity; attemptVeto error
+ // Service gains OnWorkerServing func(host string, id release.StagedIdentity): a worker's captured identity on every
+ // successful wake and respawn, whatever CARLOS_SWAP says (the agent writes registry ServingVersion with it)
// (the attempt's §7.4 checks, re-run by the respawn at the exec; what it execs instead; its refusal)
// rollout
func (s *scheduler) executeWorker(ctx context.Context, rt registry.Route, a activator.Attempt) activator.SwapResult
@@ -6903,6 +7043,69 @@ func (a *Service) awaitRespawn(ctx context.Context, done chan struct{}) bool {
A worker child that crashes during the observation is not recycled by the sweep meanwhile: the scheduler's `CoalesceRecycle` (Step 7) answers true for a host whose attempt is running, so the attempt makes the next spawn.
+**A worker's serving identity is published whatever the flag** (spec §7.9; P4 review finding 3). Plan 6 Task 13 reads `ServingVersion` for `carlos sidecars ls`, and the rollout manager writes it only with `CARLOS_SWAP=overlap`. So the activator publishes a worker's captured identity itself, on every successful wake and respawn. Add to `Service` in `activator.go`:
+
+```go
+ // OnWorkerServing, when set, hears a worker's captured serving identity
+ // each time a wake or a respawn installs a child (zero-downtime spec
+ // §7.9). It runs with no lock held. The agent sets it whatever
+ // CARLOS_SWAP says, so a flag-off box keeps `carlos sidecars ls`'s
+ // running version right (registry ServingVersion).
+ OnWorkerServing func(host string, id release.StagedIdentity)
+```
+
+In `wake()`, in its success branch, capture the identity under the lock (`servingID, publish := e.workerServing, e.worker && a.OnWorkerServing != nil`, beside plan 4's `e.workerServing = w.spawnIdentity`), and after that block's `a.mu.Unlock()`, before `close(e.ready)`, add:
+
+```go
+ if err == nil && publish && servingID.Version != "" {
+ a.OnWorkerServing(e.host, servingID)
+ }
+```
+
+In plan 4's `respawnWorkerLocked` goroutine, in the `default:` arm, after its `a.mu.Unlock()` and before its `log.Printf("activator: worker %s respawned …`, add:
+
+```go
+ if a.OnWorkerServing != nil && ident.Version != "" {
+ a.OnWorkerServing(host, ident)
+ }
+```
+
+(`ident` is the identity plan 4's arm installed as `e.workerServing`.) Append to `workerswap_test.go`:
+
+```go
+// Spec §7.9: with CARLOS_SWAP unset (no generations, no rollout manager), a
+// worker's captured serving identity is still published on its wake and on
+// every respawn, so `carlos sidecars ls` shows what runs. Only the rollout
+// manager wrote it, and a flag-off box showed "unknown", or a stale build
+// after the flag was turned off.
+func TestAWorkersServingIdentityIsPublishedWithTheFlagOff(t *testing.T) {
+ a, b, _, _, _ := stagedActivator(t)
+ var mu sync.Mutex
+ var got []string
+ a.OnWorkerServing = func(host string, id release.StagedIdentity) {
+ mu.Lock()
+ got = append(got, host+"="+id.Version)
+ mu.Unlock()
+ }
+ published := func() int { mu.Lock(); defer mu.Unlock(); return len(got) }
+ host := "bot.x.internal"
+ if err := a.WakeWorker(alwaysOnWorkerRoute(host)); err != nil {
+ t.Fatal(err)
+ }
+ waitFor(t, "the wake's identity", func() bool { return published() == 1 })
+ b.lastWorker().crash()
+ a.sweepOnce() // the always-on worker respawns
+ waitFor(t, "the respawn's identity", func() bool { return published() == 2 })
+ mu.Lock()
+ defer mu.Unlock()
+ if want := host + "=" + a.entryServing(host).Version; got[1] != want {
+ t.Fatalf("published %v, want the respawned child's %q last", got, want)
+ }
+}
+```
+
+(add `"sync"` to that file's imports).
+
- [ ] **Step 5: Run the activator tests**
Run: `TMPDIR=/tmp/k go test ./internal/activator/ -count=1`
@@ -8331,11 +8534,19 @@ In `runAgent`, directly before `svc = activator.New(*cfg, &activator.SplitBacken
activator.SetGenerations(swapOn)
eb.GenContainment = swapOn
rolloutMaterialize = eb.Materialize
+ // Whatever the flag (Task 11): a worker's serving identity is the
+ // registry's ServingVersion, which `carlos sidecars ls` reads.
+ workerServing := func(host string, id release.StagedIdentity) {
+ if err := reg.SetServingVersion(host, id.Version); err != nil {
+ log.Printf("agent: %s: record the worker's serving build %s: %v", host, id.Version, err)
+ }
+ }
```
After `svc.Logs = logSink` (line 393, still before `startActivator(...)`), add:
```go
+ svc.OnWorkerServing = workerServing // flag on or off
var rolloutSink activator.EventSink
if logSink != nil { // a typed nil in the interface would not be nil
rolloutSink = logSink