WIP: plan 3 partly fixed for P1 findings; settings plan index started (both writers hit the usage limit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+608
−21
docs/superpowers/plans/2026-10-07-instance-settings-0-index.md+412 −0docs/superpowers/plans/2026-10-07-zero-downtime-deploys-3-swap.md+196 −21
CI passed — run details
diff --git a/docs/superpowers/plans/2026-10-07-instance-settings-0-index.md b/docs/superpowers/plans/2026-10-07-instance-settings-0-index.md| new file mode 100644 |
| index 000000000..88a07e7ad |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-10-07-instance-settings-0-index.md |
| @@ -0,0 +1,412 @@ |
| +# Instance Update Settings: Plan Index |
| + |
| +> **For agentic workers:** this index holds no tasks. It fixes the order of |
| +> the five plans, what each needs from the zero-downtime plans, and the |
| +> names they share. Read it before any plan, and read the spec before both. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-10-07-instance-update-settings-design.md` |
| +(rev 10). Rev 7 was approved by Paul and accepted by Astra (S5); §4.6 (#253) |
| +was accepted by Astra in S7. The spec is the authority: where a plan and the |
| +spec disagree, the spec wins and the plan is the bug. Its §7 and §9–§13 |
| +record every review finding. None may be reopened. |
| + |
| +**Zero-downtime set:** `docs/superpowers/plans/2026-10-07-zero-downtime-deploys-0-index.md` |
| +and its six plans (written "zdd-N" below). Their "Cross-plan interface |
| +ledger" is the source of every zdd name used here. |
| + |
| +**Design branch:** `design-zero-downtime-deploys`, in the worktree |
| +`/home/paulca/amadan.net/carlos/platform-design-zero-downtime-deploys`. |
| +It holds docs only. Implementation happens on per-plan branches cut from |
| +amadan `main`; each plan names its branch and worktree. |
| + |
| +**Line numbers** in every plan are from `origin/main` at `cfdb4e12` |
| +(2026-10-07). `main` moves quickly: each implementer re-reads line numbers |
| +against the branch point, and finds each edit by the function name the plan |
| +gives beside the number. |
| + |
| +## Order |
| + |
| +| # | Plan | Branch | Lands | Depends on | |
| +|---|------|--------|-------|------------| |
| +| 1 | `…-1-pointer-digests.md`: #253. `ChannelPointer.Digest` and `HistoryDigests`; the provenance rule in promote, rollback, `FastTrack`, `RebindPointer` and (through them) the `cli.go` direct-store paths; the signed emission marker; root's digest check before staging; the static-site reader on the edge; per-box ratchets for root and the edge; `CARLOS_REQUIRE_POINTER_DIGEST`; `carlos channels resign`; the console's straggler report; its own copy review; roll notes for "writers before readers" | `settings-1-pointer-digests` | **ships first.** Inert until the marker is written: no writer emits a digest, and a ratchet only arms on a pointer that carries one | none (independent of every zdd plan) | |
| +| 2 | `…-2-promotion-records.md`: §4.1. The signed promotion record (account, app, version, manifest digest) written by every promotion; its verifier; `release.AuthorizedManifest`; `carlos releases authorize` and its console door; `DeleteVersion` removes a pruned build's record; the `internal/updatewords` package | `settings-2-promotion-records` | additive: records are written from merge on, and nothing reads them until plan 4 | 1 | |
| +| 3 | `…-3-console-settings.md`: §3, §5.1. The settings and `install_request` on the provision record; the app policy on the instances manifest; reconcile resolving defaults into one registry column; `GET`/`PUT /updates`, `POST /updates/install` and the app-policy door, for app members only (the instance-bound half is gated on #155 step 6); the console's refusal of `pin`, `manual` and `major` until every box reports the protecting binary; the control socket's read-only `GET /updates` | `settings-3-console-settings` | dormant on boxes: nothing reads the column until plan 4, and the refusal holds every non-default value until plan 4's boxes report | 2 | |
| +| 4 | `…-4-root-applies.md`: §4. `release.Target`; the consumed file; the build-state object and its registry copy; the `BeforeExec` write under the lease before every spawn path execs; `install_request` resolution, completion and the merged watermark; held-instance recovery and the wake check; failure records outranking every rule; evidence (pointer, promotion record, staged copy) per install; retention protection; the box report that lifts plan 3's refusal | `settings-4-root-applies` | the behaviour change. A record with no settings resolves to `auto` and rule 4, today's adoption, byte for byte | 1, 2, 3; zdd-1 to zdd-5 merged | |
| +| 5 | `…-5-surfaces.md`: §5. `carlos apps set` gains the policy flags; `carlos instances set`, `carlos instances update`; `carlos instances list` reasons; the console instance page's Updates section (Eleven-style); *settings not enforced*; docs; roll notes; and the copy review of every string plans 2–5 drafted, which stops for Paul | `settings-5-surfaces` | additive | 3, 4; zdd-6 merged (it adds `carlos apps set` and the instance list's INSTALLED/SERVING/REASON columns) | |
| + |
| +Plans 1 and 2 can merge and roll before any zdd plan. Plan 3 can merge |
| +before plan 4: its console refusal keeps every non-default setting off until |
| +plan 4's boxes say they enforce and protect them. Plan 4 is the only plan that |
| +changes what a box installs. |
| + |
| +**Split adjusted from the brief:** the console's refusal (§4.4 "refuses to |
| +enable … until every box … reports a binary that protects them") is in |
| +plan 3, with the door it guards, so plan 3 can never merge without it. Plan 4 |
| +adds the box-side report that lifts it. Plan 1 carries its own copy review, |
| +because it ships first and alone. |
| + |
| +## What the settings plans take from the zero-downtime plans |
| + |
| +Plan 4 is the only consumer. Its Task 0 greps every row against merged |
| +`main` before any code is written. |
| + |
| +| zdd name | Defined | Settings use | |
| +|---|---|---| |
| +| `release.GetManifestRaw(ctx, s, acct, app, version) (*Manifest, []byte, error)` | zdd-4 T2 | **shared with settings-1 T1**: whichever lands first defines it, byte for byte as zdd-4 T2 Step 3 spells it; the second skips that step (`git grep -n 'func GetManifestRaw' internal/release` decides) | |
| +| `release.StagedIdentity{Version, StagedPath, Digest, Demand}`, `release.ResolveLive`, `release.VerifyStaged`, `release.FileSHA256` | zdd-4 T3; `FileSHA256` on main (`proof.go:169`) | the retained identity: a build already staged and verified on the box is recovered from that copy with no promotion record (§4.1) | |
| +| `release.BoxPins`, `release.PinSource`, `WithPinSource` | zdd-4 T5, T6 | pinned builds and build-state builds join the pin set so a reused-name restage never removes them | |
| +| `rollout.FailureBook.Blocks(acct, app, build, host string, served bool) bool`, `rollout.OpenFailureBook(dataDir)`, `rollout.FailureFile` | zdd-5 T4 | "failure records outrank all rules" (§4.3), and "if T fails, R completes failed" | |
| +| `activator.Attempt{…, Cause}`, `CauseInstall` | zdd-3 T1 | an install request's move is an attempt with `Cause: CauseInstall` | |
| +| `Service.Swap`, `swapOverlap`'s `a.spawnSuccessor(` call, `swapHold`, `startPinned`, `StartPolicy.Fallback`, `startAdmitted` | zdd-3 T4, T6, T10 | spawn paths that must call `BeforeExec` first | |
| +| `startWorker`, `respawnWorkerLocked`, `recycleWorkerLocked` | zdd-4 T9 | worker spawn and recycle paths that must call `BeforeExec` first | |
| +| `spawnPlanned`, `spawnPlannedWorker`, `doClaimWake`'s planning block, `ReplaceWorker` | zdd-5 T9, T11 | planned-wake and socketless spawn paths that must call `BeforeExec` first | |
| +| `persistGenerations` and its call points | zdd-5 T12 | `BeforeExec` sits **after** each `persistGenerations()` call that precedes a spawn, so the generations file names the build before its build state does | |
| +| registry `GoodVersion`, `ServingVersion` | zdd-5 T2 | `GET /updates` reports serving; recovery never moves onto a build the box failed | |
| +| `status.HostRollout{Installed, Serving, …}` | zdd-5 T13 | plan 5's list and console join, beside plan 4's `HostUpdates` | |
| +| `carlos apps set` (`apps_swap.go`), `instances_cli.go` INSTALLED/SERVING/REASON, console instance page rows | zdd-6 T5, T9, T12 | plan 5 extends them | |
| + |
| +"Before any generation runs a build against the database" (§4.2) is |
| +`BeforeExec`. zdd-3 and zdd-5 have no hook of that name. Plan 4 adds the |
| +field and the calls into their code. With `CARLOS_SWAP` unset, today's wake |
| +(`doWake`, `doClaimWake`) and worker spawns call it too, because §4.2 applies |
| +whether or not generations are on. |
| + |
| +## Shared interfaces |
| + |
| +These names and types are fixed here so plans written in parallel agree. A |
| +plan may add fields. It may not rename these. |
| + |
| +### Plan 1 defines (internal/release unless stated) |
| + |
| +```go |
| +// ChannelPointer gains (additive, omitempty; signed with the payload): |
| +Digest string `json:"digest,omitempty"` // ManifestDigest of Version's manifest |
| +HistoryDigests []string `json:"history_digests,omitempty"` // one per History entry, "" where unknown |
| + |
| +func ManifestDigest(raw []byte) string // hex sha256 of the canonical manifest bytes (etagOf) |
| +func (p *ChannelPointer) DigestOf(version string) string // the signed digest for Version or a History entry; "" when none |
| +func (p *ChannelPointer) CarriesDigests() bool // p.Digest != "": what the ratchet and the flag test |
| +func (p *ChannelPointer) ChannelHasDigests() bool // head or any history entry carries one: the rollback rule |
| +var ErrManifestDigest = errors.New("release: manifest does not hash to the digest its signer recorded") |
| +func CheckManifestDigest(ctx context.Context, s Store, acct, app, version, want string) (*Manifest, []byte, error) // want "": no check |
| + |
| +// The emission marker (root-signed, SignRootEnvelope / VerifyRootEnvelope): |
| +func PointerDigestsKey(acct string) string // "releases/<acct>/pointer-digests" |
| +type DigestMarker struct { |
| + Schema int `json:"schema"` // 1 |
| + Account string `json:"account"` |
| + At time.Time `json:"at"` |
| + By string `json:"by,omitempty"` |
| +} |
| +func WriteDigestMarker(ctx context.Context, s Store, sg Signer, acct, by string) error |
| +func RemoveDigestMarker(ctx context.Context, s Store, acct string) error |
| +func ReadDigestMarker(ctx context.Context, s Store, pub ed25519.PublicKey, acct string) (*DigestMarker, error) // (nil, nil) when absent |
| + |
| +// The per-box ratchet: |
| +const RequirePointerDigestEnv = "CARLOS_REQUIRE_POINTER_DIGEST" |
| +var ErrPointerWithoutDigests = errors.New("release: pointer carries no digests") |
| +type DigestRatchet struct{ /* unexported */ } |
| +func NewDigestRatchet(own string, also []string, require bool) *DigestRatchet // own: the dir this process writes; also: dirs it only reads |
| +func RootRatchetDir(optDir string) string // <optDir>/pointer-digests (/opt/carlos/pointer-digests) |
| +func EdgeRatchetDir(dataDir string) string // <dataDir>/pointer-digests (/data/pointer-digests) |
| +func (r *DigestRatchet) Admit(acct string, p *ChannelPointer) error // nil-safe: a nil ratchet admits everything |
| +func WithDigestRatchet(r *DigestRatchet) PassOption |
| + |
| +// carlos channels resign: |
| +type ResignPlan struct { |
| + Account, App, Channel string |
| + Version string |
| + Digest string // computed now from the bucket, shown to the operator |
| + History []string |
| + HistoryDigests []string // after resign: every entry the bucket can answer for |
| + Computed []bool // per entry (index 0 = head): true when resign computed it rather than copying it |
| + etag string |
| +} |
| +func (sg Signer) PlanResign(ctx context.Context, s Store, acct, app, channel string) (*ResignPlan, error) |
| +func (sg Signer) Resign(ctx context.Context, s Store, plan *ResignPlan, actor string) error // CAS on the plan's etag |
| + |
| +// The straggler report: |
| +type ChannelDigestState struct { |
| + Channel string `json:"channel"` |
| + Version string `json:"version"` |
| + PromotedAt time.Time `json:"promoted_at"` |
| + HeadDigest bool `json:"head_digest"` // the head carries a digest |
| + HistoryMissing int `json:"history_missing"` // history entries with none |
| + Straggler bool `json:"straggler"` // no head digest, marker on, PromotedAt after the marker |
| +} |
| +func DigestReport(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string) ([]ChannelDigestState, *DigestMarker, error) |
| +``` |
| + |
| +### Plan 2 defines (internal/release) |
| + |
| +```go |
| +func PromotionRecordKey(acct, app, version string) string // "releases/<acct>/<app>/promoted/<version>" |
| +type PromotionRecord struct { |
| + Schema int `json:"schema"` // 1 |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| + Version string `json:"version"` |
| + ManifestDigest string `json:"manifest_digest"` |
| + Channel string `json:"channel,omitempty"` // the channel promoted to; "" for an authorize |
| + Authorized bool `json:"authorized,omitempty"` // written by `carlos releases authorize`, not a promotion |
| + Actor string `json:"actor,omitempty"` |
| + At time.Time `json:"at"` |
| +} |
| +var ErrNoPromotionRecord // wraps os.ErrNotExist |
| +func (sg Signer) WritePromotionRecord(ctx context.Context, s Store, rec PromotionRecord) error // signedWrapper, grant attached for a scoped signer |
| +func ReadPromotionRecord(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, version string) (*PromotionRecord, error) |
| +func AuthorizedManifest(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, version string) (*Manifest, error) // record + CheckManifestDigest |
| +type AuthorizePlan struct { Account, App, Version, ManifestDigest string; Artifacts map[string]Artifact } |
| +func PlanAuthorize(ctx context.Context, s Store, acct, app, version string) (*AuthorizePlan, error) |
| +func (sg Signer) Authorize(ctx context.Context, s Store, plan *AuthorizePlan, actor string) error // root only; re-reads, refuses if the digest moved |
| +``` |
| + |
| +### Plan 3 defines |
| + |
| +```go |
| +// internal/release/updatesettings.go |
| +const ( |
| + UpdatesAuto = "auto" |
| + UpdatesManual = "manual" |
| + ControlUpdates, ControlChannel, ControlMajor, ControlPin = "updates", "channel", "major", "pin" |
| +) |
| +type UpdateSettings struct { // the EFFECTIVE settings: defaults already resolved by reconcile |
| + Updates string `json:"updates"` // UpdatesAuto | UpdatesManual |
| + Channel string `json:"channel"` |
| + Major *int `json:"major,omitempty"` |
| + Pin string `json:"pin,omitempty"` |
| + InstallRequest uint64 `json:"install_request,omitempty"` |
| + InstallFastTrack bool `json:"install_fast_track,omitempty"` |
| + Control []string `json:"control,omitempty"` // the app's instance_control |
| +} |
| +func ParseUpdateSettings(col string) (UpdateSettings, error) // "" parses to {Updates: auto}: today's behaviour |
| +func (u UpdateSettings) Column() string // canonical JSON for the registry column |
| +func (u UpdateSettings) Default() bool // auto, no major, no pin: what every row is before this feature |
| + |
| +// internal/provision |
| +// Record gains: Updates string `json:"updates,omitempty"`; Major *int `json:"major,omitempty"`; |
| +// Pin string `json:"pin,omitempty"`; InstallRequest *InstallRequest `json:"install_request,omitempty"` |
| +type InstallRequest struct { ID uint64 `json:"id"`; FastTrack bool `json:"fast_track,omitempty"`; At time.Time `json:"at"`; By string `json:"by,omitempty"` } |
| +// Manifest gains: Updates *UpdatePolicy `json:"updates,omitempty"`. The default channel stays Manifest.Channel. |
| +type UpdatePolicy struct { |
| + InstanceControl []string `json:"instance_control,omitempty"` |
| + DefaultUpdates string `json:"default_updates,omitempty"` |
| + DefaultMajor *int `json:"default_major,omitempty"` |
| +} |
| +func EffectiveUpdates(rec Record, m *Manifest) release.UpdateSettings |
| + |
| +// internal/registry |
| +// Route gains: UpdateSettings string (column update_settings, "" on every pre-existing row) |
| +func (r *Registry) SetUpdateSettings(host, col string) error |
| + |
| +// internal/status |
| +const UpdateSettingsProtocol = 1 |
| +// BoxMetrics gains: UpdateSettings int `json:"update_settings,omitempty"` (absent: a binary that ignores settings) |
| +// console: func (s *Server) updatesEnforced(ctx context.Context) (ok bool, blocking []string, err error) |
| + |
| +// internal/instanceupdates (control socket, read-only) |
| +type Report struct { |
| + Host string `json:"host"` |
| + Settings release.UpdateSettings `json:"settings"` |
| + MayChange []string `json:"may_change"` |
| + Installed string `json:"installed,omitempty"` |
| + Serving string `json:"serving,omitempty"` |
| + Desired string `json:"desired,omitempty"` |
| + Reason string `json:"reason,omitempty"` |
| +} |
| +func Mount(mux *http.ServeMux, host string, token func() (string, error), read func(host string) (Report, error)) |
| +``` |
| + |
| +Console doors (all `svcrole.Set{}`, person-only, until #155 step 6 adds the |
| +instance-bound credential; spec §3.3, §5.1): |
| + |
| +``` |
| +GET /api/cli/apps/{account}/{app}/instances/{host}/updates |
| +PUT /api/cli/apps/{account}/{app}/instances/{host}/updates |
| +POST /api/cli/apps/{account}/{app}/instances/{host}/updates/install |
| +PUT /api/cli/apps/{account}/{app}/updates (app policy) |
| +``` |
| + |
| +### Plan 4 defines |
| + |
| +```go |
| +// internal/release |
| +type ResolvedRequest struct { ID uint64 `json:"id"`; Target string `json:"target"` } |
| +type BuildState struct { |
| + Schema int `json:"schema"` // 1 |
| + Host string `json:"host"` |
| + LastExecuted string `json:"last_executed,omitempty"` |
| + Resolved *ResolvedRequest `json:"resolved,omitempty"` |
| + Completed uint64 `json:"completed,omitempty"` |
| + CompletedFailed bool `json:"completed_failed,omitempty"` // the request at Completed completed failed |
| + At time.Time `json:"at"` |
| +} |
| +func BuildStateKey(replicaPrefix string) string // replicaPrefix + "/build-state.json", beside lock.json |
| +type Consumed struct { // /opt/carlos/consumed/<host>, root-owned |
| + Schema int `json:"schema"` |
| + Host string `json:"host"` |
| + Resolved *ResolvedRequest `json:"resolved,omitempty"` |
| + Completed uint64 `json:"completed,omitempty"` |
| + CompletedFailed bool `json:"completed_failed,omitempty"` |
| + Desired string `json:"desired,omitempty"` // root's last decision, for reports |
| + Reason string `json:"reason,omitempty"` // a TargetReason |
| + At time.Time `json:"at"` |
| +} |
| +func ConsumedPath(optDir, host string) string |
| +func ReadConsumed(optDir, host string) (*Consumed, error) // (nil, nil) absent |
| +func WriteConsumed(optDir string, c Consumed) error |
| +func Watermark(c *Consumed, b *BuildState) uint64 // max of both: never one in place of the other |
| + |
| +type TargetReason string // "adopt" "pin" "request" "manual" "major" "unknown" "new" "baking" "failed" "unauthorized" |
| +type Evidence int // EvidencePointer, EvidenceRecord, EvidenceStaged |
| +type TargetInput struct { |
| + Route registry.Route |
| + Pointer *ChannelPointer // the verified head; nil when unresolved |
| + Settings UpdateSettings |
| + Build *BuildState |
| + Consumed *Consumed |
| + Installed string |
| + HasReplica bool |
| + HeadHeld string // Hold(...) for the head on this box: "" = eligible |
| + Failed func(version string) bool // zdd-5 FailureBook.Blocks for this host |
| +} |
| +type TargetDecision struct { |
| + Version string // "" installs nothing |
| + Reason TargetReason |
| + Evidence Evidence |
| + Bake bool |
| + Resolve *ResolvedRequest // this pass resolves a pending request |
| + Fail uint64 // this pass completes request Fail as failed |
| +} |
| +func Target(in TargetInput) TargetDecision |
| + |
| +// internal/registry |
| +// Route gains: BuildState string (column build_state): the agent's copy, JSON of release.BuildState |
| +func (r *Registry) SetBuildState(host, col string) error |
| + |
| +// internal/activator |
| +// Service gains: BeforeExec func(ctx context.Context, host, build string) error |
| +// Called under the instance's lease before every spawn that execs build. A non-nil |
| +// error refuses the spawn: a wake fails as a wake fails today; a swap is abandoned. |
| + |
| +// internal/status |
| +type HostUpdates struct { |
| + Desired string `json:"desired,omitempty"` |
| + Reason string `json:"reason,omitempty"` |
| + Pending uint64 `json:"pending,omitempty"` // an install request above the watermark |
| + Completed uint64 `json:"completed,omitempty"` |
| + CompletedFailed bool `json:"completed_failed,omitempty"` |
| +} |
| +// BoxMetrics gains: Updates map[string]HostUpdates `json:"updates,omitempty"` (host-keyed, private) |
| +``` |
| + |
| +### Words (plans 2–5) |
| + |
| +Every member-facing string plans 2–5 add is a draft with an ID |
| +(`settings.*`) in one leaf package, `internal/updatewords`, which **plan 2 |
| +creates** (it merges first). Each plan adds its own file of drafts, so |
| +parallel branches never edit the same lines: |
| + |
| +```go |
| +// internal/updatewords/words.go (plan 2) |
| +type Draft struct{ ID, Text, Context string } // Context: where it appears and what it does, for the reviewer |
| +func add(ds ...Draft) // called from each file's init; panics on a duplicate ID |
| +func Text(id string) string // the text for id; panics on an unknown id |
| +func All() []Draft // sorted by ID: what plan 5's copy review exports |
| +// files: words_authorize.go (plan 2), words_console.go (plan 3), words_box.go (plan 4), words_surfaces.go (plan 5) |
| +``` |
| + |
| +Plan 5's Task 1 exports `All()`, reviews every draft with Paul, and replaces |
| +each `Text` with the approved wording; a test then pins every draft to |
| +`docs/superpowers/plans/2026-10-07-instance-settings-5-copy.json`. Operator |
| +journal lines on a box are not member-facing, and stay where they are |
| +printed. Plan 1's strings are reviewed in plan 1. |
| + |
| +## Global constraints (every plan) |
| + |
| +- `go build ./...`, `go vet ./...`, `gofmt -l .` and the touched packages' |
| + tests are clean before every commit. The full `make test` gate runs in each |
| + plan's last task. |
| +- Use the default Go build cache. Never set `GOCACHE`, `GOMODCACHE` or |
| + `GOTMPDIR` under `/tmp`. Run `go` as `GOFLAGS=-mod=mod GOPROXY=off go …`; if |
| + the sandbox answers "read-only file system", rerun that one command with |
| + the sandbox off. Socket-binding packages (`internal/activator`, |
| + `internal/host`) run with `TMPDIR=/tmp/k` (`mkdir -p /tmp/k`). |
| +- Scratch goes under `$TMPDIR` or `~/.cache/<plan-branch>/`, and the last |
| + task deletes it. |
| +- CI cross-compiles darwin, windows and linux-arm64. Any Linux-only syscall |
| + lives in a `_linux.go` file with a `!linux` stub. |
| +- **Wire shapes are additive** (AGENTS.md). Every new pointer, record, |
| + manifest and status field is `omitempty`, and a document written before it |
| + still parses. Old binaries verify a pointer with digests (spec §8). |
| +- **The default is today.** A record with no settings, an app with no policy, |
| + and a box with no marker behave exactly as before. Each plan proves it. |
| +- No user-facing string ships without copy review. No em dashes in any |
| + user-facing string. |
| +- Commits: `git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit`, |
| + one per task, message `settings-N task M: <what>`, ending with |
| + `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. Never push to |
| + `main`. Land with `amadan branch merge carlos/platform <branch> -expect <tip> |
| + -message "..."` after CI is green and Paul says so. |
| +- Plan 4 is concurrency-heavy (the request lifecycle across boxes): it ends |
| + with an invariant simulation run serially at nice 19, one 3000-seed sweep, |
| + per the CPU budget note. |
| + |
| +## Review focus (cross-plan) |
| + |
| +1. **A box upgraded mid-rollout of #253.** A box whose ratchet has armed |
| + meets a pointer from a writer that was not upgraded. Expected: that |
| + channel stops moving on that box, it keeps serving what it has, and the |
| + console's straggler report names the channel. Plan 1 owns it. |
| +2. **An instance with a replica and no build state lands on a fresh box with |
| + `manual`.** Expected: nothing is installed, the reason is *build unknown*, |
| + and the instance stays down rather than booting the head. Plan 4 owns it. |
| +3. **"Update now" pressed on two consoles within a second.** Expected: two |
| + increasing request ids, the higher one wins, and each completes at most |
| + once. Plan 3 owns the id, plan 4 the completion. |
| +4. **A pin on a build promoted before this feature, on a box that has it |
| + staged.** Expected: recovered from the staged copy; anywhere else, refused |
| + with the authorize hint. Plan 4 owns it. |
| +5. **Retention while a box still runs a binary from before plan 4.** |
| + Expected: the console refuses `pin`, `manual` and `major`; the housekeeper |
| + prunes nothing it cannot prove unneeded. Plans 3 and 4 own it. |
| + |
| +## Spec questions found while planning (not resolved here; listed for Paul) |
| + |
| +- **Q1. Build-state objects live in regional data buckets.** §4.2 puts the |
| + build state "beside the instance's replica" (`CARLOS_HIBERNATE_BUCKET`, per |
| + region). §4.4 says the housekeeper prunes nothing for an app unless it can |
| + read every build-state object of the app. The deployment-wide housekeeper's |
| + role cannot read another region's data bucket (`infra/modules/region-host` |
| + grants sibling regions `moves/*` push only, never `GetObject`). Read |
| + literally, an app with instances in a second region is never pruned again. |
| + Plan 4 implements the literal, fail-closed reading and logs why. |
| +- **Q2. `release.Target(route, pointer, settings)`** takes more than those |
| + three: the build state, root's consumed file, the installed build, whether |
| + a replica exists, this box's bake verdict and the failure records. Plan 4 |
| + passes one `TargetInput` struct. Root is still the only caller that |
| + installs. |
| +- **Q3. The console API path.** §5.1 names `/api/v1/instances/<host>/updates`. |
| + The console has no `/api/v1`; every instance door is |
| + `/api/cli/apps/{account}/{app}/instances/{host}/…`. Plan 3 uses the house |
| + path. |
| +- **Q4. Default channel and default pin.** `Record.Channel` is required and |
| + set at create, so a "default channel" only ever reaches new instances; plan |
| + 3 maps `--default-channel` to the existing `instances.json` `channel`. §3.2's |
| + table implies a default pin, which §5 gives no flag; plan 3 offers none. |
| +- **Q5. No command writes the emission marker.** §4.6 says the marker "is |
| + written". Plan 1 adds `carlos channels digests on|off` (root key, |
| + direct-store only, like other deployment-control writes). |
| +- **Q6. The moves held stage installs a version with no pointer.** |
| + `release.StageHeld` and `PublishHeld` (`materializeheld.go`) stage a |
| + version named by a move, checked only by the D3 proof gate. §4.5 says |
| + "nothing can make root install a build that §4.1 doesn't authorize". It is |
| + not an install this spec adds, so no plan changes it; it is listed. |
| +- **Q7. `install_request` with FastTrack.** §4.3 lets a request ask for the |
| + current head past bake. Today only the root key waives bake (`FastTrack`). |
| + Plan 3 lets only a person who may fast-track the channel today set |
| + `fast_track` on a request. |
| +- **Q8. Who may authorize and resign.** Both sign with the deployment's |
| + key. Plan 2 and plan 1 make them root-key operations (the console signs |
| + with its key for an app member, as it does for promote; a direct-store CLI |
| + with a scoped key is refused). |
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 4ef66f0bb..a18fe9ad2 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 |
| @@ -65,7 +65,7 @@ The index fixes `Gen`, `GenSocketPath` and `AppSocket`. Plans 1 and 2 were draft |
| | C4 | plan 1 T3 | `func (a *Service) noteCurrent(host string, n uint64)`, `func (a *Service) genRetired(host string, g Gen)` | `noteCurrent` is the cache-generation bump (a `sync.Map`, safe under `a.mu`). `genRetired` closes the edge's idle connections to `g`, and must be called with no lock held. | |
| | C5 | plan 1 T2, plan 2 T10–T11 | `ExecBackend.flatGenSocket(host, n)`, `nestedGenSocketIn(runDir, n)`, `clearOwnGenSocket(sock)`, `startChildAt(ctx, rt, sock string, n uint64)`, `startUnitAt(ctx, rt, sock string, n uint64)`, `prepareAt(rt, role, sock string, gen uint64)`, `normalizeSocketMode(rt, sock, proc)`, `listenControl(rt, control, envPath)` | Plan 1's own `GenBackend.StartGen(ctx, rt, n, perGen)` is the wake's spawn, and it execs the **live link**. Plan 2 adds the trailing generation number to plan 1's `startChildAt`, `startUnitAt` and `prepareAt` (`Start` passes 0). Plan 3 adds a staged-path spawn beside it. | |
| | C6 | plan 1 (spec rev 15) | `swapCanonicalLink(canonical, target string) error` | Replaces the canonical path with a symlink to `target`, atomically: a temp link, then rename. | |
| -| C7 | plan 2 T10 | `ExecBackend.GenContainment`, `ParentLimit(ctx, rt, cgroup.Limit) error`, `cgroup.OverlapLimit(inc, succ)`, `ExecBackend.tier(rt)` (on `main`) | `ParentLimit` answers `errNoGenContainment` when `GenContainment` is off. Plan 2's `KillGen(ctx, rt, n uint64)` is not used here: a failed successor is stopped through the operation set. Plan 5 calls `KillGen` for a failed first wake. | |
| +| C7 | plan 2 T10 | `ExecBackend.GenContainment`, `ParentLimit(ctx, rt, cgroup.Limit) error`, `KillGen(ctx, rt, n uint64) error` (and `SplitBackend.KillGen`), `errNoGenContainment`, `cgroup.OverlapLimit(inc, succ)`, `ExecBackend.tier(rt)` (on `main`) | `ParentLimit` answers `errNoGenContainment` when `GenContainment` is off. `KillGen` kills what is left of generation n's own child and returns once it is empty, or wraps `errNoGenContainment` when there are no generation children. Task 2's `stopMember` calls it after every member's graceful drain, because a leader's exit does not prove its child empty. Plan 5 also calls it for a failed first wake, through Task 2's `genKiller`. | |
| | C8 | plan 2 T9, T11 | `instancectl.Client.StartGen(ctx, host, n, memoryMax)`, `activator.GenControl`, `Server.startGen`, `Server.ensureGenExe(host, n)`, `Server.refuseStart(w, host, unit, exe func() error)`, `genValues(host, n)`, the server's `request` struct, `ExecBackend.startRole(ctx, rt, role, gen)` | The nested generation start. Its executable mount targets the live link until Task 9. | |
| | C9 | plan 2 T12 | `(*cgroup.Manager).PrepareStrictParent(host, tier)`, `(*cgroup.Prepared).SetParentLimit(l)`, `(*cgroup.Prepared).CloseRemove()`, `spawnClaimedGen(ctx, parent, n, tier, work, identity, build, before)` | Plan 2 leaves the entry's strict parent and its life to plan 3 (Task 10). | |
| @@ -703,7 +703,7 @@ git commit -m "zdd plan 3 task 1: swap types, the SwapBackend seam, flat staged |
| - Test: `internal/activator/opset_test.go` |
| **Interfaces:** |
| -- Consumes: plan 1's `a.gens` (C1); `Gen` (C1); `SwapBackend.RestoreGenParent` (Task 1). |
| +- Consumes: plan 1's `a.gens` (C1); `Gen` (C1); `SwapBackend.RestoreGenParent` (Task 1); plan 2's `KillGen(ctx, rt, n)` on `ExecBackend` and `SplitBackend`, and its `errNoGenContainment` (C7). |
| - Produces: |
| ```go |
| type genRole int; const roleSpawning, roleSuccessor, roleRetiring genRole |
| @@ -711,14 +711,20 @@ git commit -m "zdd plan 3 task 1: swap types, the SwapBackend seam, flat staged |
| type opSet struct{ members map[uint64]*genMember; raised bool } |
| // actEntry gains: ops opSet |
| var opJoinBound time.Duration |
| + // genKiller is plan 2's generation kill, as a capability. Plan 3 owns the |
| + // name; plan 5 uses it and does not declare its own. |
| + type genKiller interface{ KillGen(ctx context.Context, rt registry.Route, n uint64) error } |
| func (a *Service) addMemberLocked(e *actEntry, m *genMember) |
| func (a *Service) isMember(e *actEntry, m *genMember) bool |
| func (a *Service) joinOps(e *actEntry) bool |
| - func (a *Service) stopMember(m *genMember) bool |
| + func (a *Service) stopMember(e *actEntry, m *genMember) bool // leader exit AND an empty generation child |
| + func (a *Service) genChildEmpty(e *actEntry, m *genMember) bool |
| func (a *Service) dropMember(e *actEntry, m *genMember) bool |
| func (a *Service) leaveSet(e *actEntry, m *genMember) |
| func (a *Service) reapMembersLocked(e *actEntry) |
| + func (a *Service) reapExited(e *actEntry, m *genMember) |
| ``` |
| + A generation is terminated when its leader has exited **and** its generation child cgroup is proven empty (spec §7.3, §7.4). The flat `childProc.Stop` signals only the leader (`backend_exec.go`, `func (p *childProc) Stop`), and plan 2's exit goroutine only tries `RemoveChild`, which the kernel refuses while a forked descendant lives. So after the graceful drain, `stopMember` calls plan 2's `KillGen` for the member's generation, which kills what is left of that child and returns once it is empty. A failed kill keeps the member in the set, unproven, exactly as `ErrStillRunning` does. |
| - [ ] **Step 1: Write the failing tests** |
| @@ -730,9 +736,13 @@ package activator |
| import ( |
| "context" |
| "errors" |
| + "fmt" |
| "slices" |
| + "sync" |
| "testing" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| ) |
| // plantMember puts a generation into host's operation set the way Swap does |
| @@ -877,6 +887,109 @@ func TestBarrierKeepsTheLeaseWhileAGenerationIsUnproven(t *testing.T) { |
| } |
| } |
| +// killGenBackend is fakeBackend plus plan 2's KillGen. err, while set, is |
| +// what every kill answers: a generation child that still holds a process. |
| +type killGenBackend struct { |
| + *fakeBackend |
| + kmu sync.Mutex |
| + err error |
| + kills []uint64 |
| +} |
| + |
| +func (k *killGenBackend) KillGen(_ context.Context, _ registry.Route, n uint64) error { |
| + k.kmu.Lock() |
| + defer k.kmu.Unlock() |
| + k.kills = append(k.kills, n) |
| + return k.err |
| +} |
| + |
| +func (k *killGenBackend) setErr(err error) { |
| + k.kmu.Lock() |
| + k.err = err |
| + k.kmu.Unlock() |
| +} |
| + |
| +func (k *killGenBackend) killed() []uint64 { |
| + k.kmu.Lock() |
| + defer k.kmu.Unlock() |
| + return slices.Clone(k.kills) |
| +} |
| + |
| +// A generation's leader can exit while a process it forked lives on in its |
| +// child cgroup. The leader's exit is not termination (spec §7.3, §7.4). |
| +// After the graceful drain the member's own child is killed and proven |
| +// empty through plan 2's KillGen. While that proof fails, the member stays |
| +// in the set and the lease stays held, exactly as for ErrStillRunning. The |
| +// reaper retries the kill and finishes the stop once the child is empty. |
| +// |
| +// Without the KillGen step, stopMember accepts Exited() alone: the member |
| +// leaves the set at once, "marked unproven" never happens, and the Bounce |
| +// releases the lease over the surviving descendant. |
| +func TestSwapWitnessMemberStopProvesTheGenerationChildEmpty(t *testing.T) { |
| + x := awakeForBarrier(t, true) |
| + old := opJoinBound |
| + opJoinBound = 100 * time.Millisecond |
| + t.Cleanup(func() { opJoinBound = old }) |
| + kb := &killGenBackend{fakeBackend: x.b, err: errors.New("g9 still holds a process")} |
| + x.a.backend = kb |
| + member := &fakeProc{label: "gen:9", rec: x.b} // its leader exits on SIGTERM |
| + plantMember(t, x.a, x.host, roleRetiring, member, 9) |
| + waitFor(t, "the member marked unproven", func() bool { |
| + x.a.mu.Lock() |
| + defer x.a.mu.Unlock() |
| + m := x.a.entries[x.host].ops.members[9] |
| + return m != nil && m.unproven |
| + }) |
| + if !member.isStopped() || !member.Exited() { |
| + t.Fatal("the graceful stop never ran, or the leader did not exit") |
| + } |
| + if got := kb.killed(); !slices.Equal(got, []uint64{9}) { |
| + t.Fatalf("KillGen calls = %v, want [9] (the member's own generation)", got) |
| + } |
| + rep := x.b.lastRep() |
| + x.a.Bounce(x.host) |
| + waitFor(t, "the stop to pend", func() bool { |
| + x.a.mu.Lock() |
| + defer x.a.mu.Unlock() |
| + e := x.a.entries[x.host] |
| + return e != nil && e.pendingStop |
| + }) |
| + if rep.isClosed() { |
| + t.Fatal("the lease was released while generation 9's child still held a process") |
| + } |
| + x.a.sweepOnce() // the reaper retries the kill, which still fails |
| + time.Sleep(50 * time.Millisecond) |
| + if rep.isClosed() { |
| + t.Fatal("the lease was released on a failed retry") |
| + } |
| + kb.setErr(nil) |
| + x.a.sweepOnce() // the kill proves the child empty now |
| + waitFor(t, "the member reaped", func() bool { |
| + x.a.mu.Lock() |
| + defer x.a.mu.Unlock() |
| + e := x.a.entries[x.host] |
| + return e == nil || len(e.ops.members) == 0 |
| + }) |
| + x.a.sweepOnce() // finishes the stop |
| + waitFor(t, "the lease release", rep.isClosed) |
| +} |
| + |
| +// A backend with no generation children (containment off answers |
| +// errNoGenContainment) has nothing more to prove than the leader's exit: |
| +// the generation shares the host cgroup, whose emptiness the terminal |
| +// stop's own evidence proves. |
| +func TestMemberStopWithoutContainmentAcceptsTheLeaderExit(t *testing.T) { |
| + x := awakeForBarrier(t, true) |
| + kb := &killGenBackend{fakeBackend: x.b, err: fmt.Errorf("wrapped: %w", errNoGenContainment)} |
| + x.a.backend = kb |
| + m := plantMember(t, x.a, x.host, roleRetiring, &fakeProc{label: "gen:9", rec: x.b}, 9) |
| + select { |
| + case <-m.done: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("the member never left the set") |
| + } |
| +} |
| + |
| // With swaps on, Shutdown covers a waking entry (spec §7.4): it cancels the |
| // wake and waits for it. Its child is stopped and its lease given back |
| // before Shutdown returns. |
| @@ -946,7 +1059,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|TestSwapWitnessShutdownJoins|TestShutdownLeaves' -count=1` |
| +Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapWitnessBarrier|TestBarrierKeeps|TestMemberStop|TestSwapWitnessShutdownJoins|TestShutdownLeaves' -count=1` |
| Expected: build failure: `undefined: genRole`, `undefined: genMember`, `x.a.entries[x.host].ops undefined`. |
| - [ ] **Step 3: Write `opset.go`** |
| @@ -969,6 +1082,7 @@ import ( |
| "time" |
| "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/registry" |
| ) |
| type genRole int |
| @@ -1066,11 +1180,21 @@ func (a *Service) joinOps(e *actEntry) bool { |
| return true |
| } |
| +// genKiller is plan 2's generation kill (ExecBackend.KillGen and its |
| +// SplitBackend forward): it kills what is left of generation n's own child |
| +// cgroup and returns once that child is empty. Plan 5 uses this name too. |
| +type genKiller interface { |
| + KillGen(ctx context.Context, rt registry.Route, n uint64) error |
| +} |
| + |
| // stopMember stops m's process, bounded by actStopTimeout: SIGTERM, the |
| // generation's own graceful drain, then SIGKILL (spec §7.5 step 6). It |
| -// reports whether termination is proven. A member that never spawned is |
| +// reports whether termination is proven, which takes two things: the |
| +// leader has exited, and the generation's child cgroup is empty (spec |
| +// §7.3, §7.4). The flat childProc signals only its leader, so a process |
| +// the app forked can outlive it in g<n>. A member that never spawned is |
| // trivially proven. |
| -func (a *Service) stopMember(m *genMember) bool { |
| +func (a *Service) stopMember(e *actEntry, m *genMember) bool { |
| if m.proc == nil { |
| return true |
| } |
| @@ -1083,14 +1207,46 @@ func (a *Service) stopMember(m *genMember) bool { |
| if err != nil { |
| log.Printf("activator: stop generation %d: %v", m.gen.N, err) |
| } |
| - return m.proc.Exited() |
| + if !m.proc.Exited() { |
| + return false |
| + } |
| + return a.genChildEmpty(e, m) // swapsim:M7 |
| +} |
| + |
| +// genChildEmpty proves m's generation child empty, after its leader has |
| +// exited, through plan 2's KillGen: whatever is left in g<n> (or the |
| +// nested carlos-instance-gen@<id>:<n> unit) is killed and the child is |
| +// waited empty. That kill is the only one that can reach a descendant the |
| +// leader left behind, and it touches no other generation. |
| +// |
| +// A backend with no generation kill (a test double), or one that answers |
| +// errNoGenContainment, has no generation children: the generation ran in |
| +// the host cgroup, and the terminal stop's own stop evidence on that cgroup |
| +// is the proof (plan 2, "stop evidence on the parent"). Any other error is |
| +// an unproven termination. |
| +func (a *Service) genChildEmpty(e *actEntry, m *genMember) bool { |
| + k, ok := a.backend.(genKiller) |
| + if !ok || m.gen.N == 0 { |
| + return true |
| + } |
| + a.mu.Lock() |
| + rt := e.route |
| + a.mu.Unlock() |
| + ctx, cancel := context.WithTimeout(context.Background(), instancectl.DeadlineControl) |
| + defer cancel() |
| + err := k.KillGen(ctx, rt, m.gen.N) |
| + if err == nil || errors.Is(err, errNoGenContainment) { |
| + return true |
| + } |
| + log.Printf("activator: %s: generation %d's leader exited but its child is not proven empty: %v", rt.Host, m.gen.N, err) |
| + return false |
| } |
| // dropMember stops m and takes it out of e's set once its termination is |
| // proven. It reports whether termination was proven. An unproven member |
| // stays in the set, marked, and the sweep's reaper finishes it. |
| func (a *Service) dropMember(e *actEntry, m *genMember) bool { |
| - proven := a.stopMember(m) // swapsim:M4 |
| + proven := a.stopMember(e, m) // swapsim:M4 |
| if !proven { |
| a.mu.Lock() |
| m.unproven, m.cancel, m.retriedAt = true, nil, a.now() |
| @@ -1133,8 +1289,9 @@ func (a *Service) leaveSet(e *actEntry, m *genMember) { |
| } |
| // reapMembersLocked finishes members whose owner could not prove their stop. |
| -// Once a member's process has exited, the reaper takes it out of the set. |
| -// Until then it re-issues Stop every actStopTimeout, the cadence |
| +// Once a member's process has exited, the reaper proves its generation |
| +// child empty (reapExited) and only then takes it out of the set. Until the |
| +// leader exits it re-issues Stop every actStopTimeout, the cadence |
| // finishPendingStops keeps for the main process. The caller holds a.mu. |
| func (a *Service) reapMembersLocked(e *actEntry) { |
| for _, m := range e.ops.members { |
| @@ -1143,7 +1300,7 @@ func (a *Service) reapMembersLocked(e *actEntry) { |
| } |
| if m.proc.Exited() { |
| m.reaping = true |
| - go a.leaveSet(e, m) |
| + go a.reapExited(e, m) |
| continue |
| } |
| if a.now().Sub(m.retriedAt) < actStopTimeout { |
| @@ -1160,6 +1317,19 @@ func (a *Service) reapMembersLocked(e *actEntry) { |
| }(m) |
| } |
| } |
| + |
| +// reapExited finishes an unproven member whose leader has exited: it leaves |
| +// the set only once its generation child is proven empty. A failed proof |
| +// leaves it marked, and the next sweep tries the kill again. |
| +func (a *Service) reapExited(e *actEntry, m *genMember) { |
| + if a.genChildEmpty(e, m) { |
| + a.leaveSet(e, m) |
| + return |
| + } |
| + a.mu.Lock() |
| + m.reaping, m.retriedAt = false, a.now() |
| + a.mu.Unlock() |
| +} |
| ``` |
| - [ ] **Step 4: Wire the set into `activator.go`** |
| @@ -1297,7 +1467,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|TestSwapWitnessShutdownJoins|TestShutdownLeaves' -count=1 -v 2>&1 | tail -20` |
| +Run: `TMPDIR=/tmp/k nice -n 19 go test ./internal/activator -run 'TestSwapWitnessBarrier|TestBarrierKeeps|TestMemberStop|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)** |
| @@ -1604,7 +1774,7 @@ git commit -m "zdd plan 3 task 3: swap admission through hostwrite, claims off a |
| func (a *Service) genHealthy(ctx context.Context, g Gen) error |
| // Service gains: genHealth func(ctx context.Context, g Gen) error (test seam) |
| var errSwapOff, errSwapBusy, errSwapKind, errSwapStopped, errSuccessorExited, errSwapMode error |
| - // test fixture: fakeSwapBackend, servingProc, swapFixture, newSwapFixture, setCurrentGen, (*Service).genStateForTest, indexPrefix |
| + // test fixture: fakeSwapBackend, genServingProc (named apart from plan 1 Task 4's servingProc test type, which is in the same package), swapFixture, newSwapFixture, setCurrentGen, (*Service).genStateForTest, indexPrefix |
| ``` |
| - [ ] **Step 1: Write the test fixture** |
| @@ -1677,7 +1847,7 @@ func (g *fakeSwapBackend) StartStaged(ctx context.Context, _ registry.Route, gen |
| p := &fakeProc{label: fmt.Sprintf("gen:%d", gen.N), rec: g.fakeBackend} |
| var proc Proc = p |
| if g.genServe != nil { |
| - proc = &servingProc{fakeProc: p, stop: g.genServe(gen)} |
| + proc = &genServingProc{fakeProc: p, stop: g.genServe(gen)} |
| } |
| g.gmu.Lock() |
| g.gens = append(g.gens, gen) |
| @@ -1722,13 +1892,16 @@ func (g *fakeSwapBackend) lastGen() (Gen, *fakeProc) { |
| return g.gens[len(g.gens)-1], g.genProcs[len(g.genProcs)-1] |
| } |
| -// servingProc is a generation child with a real socket server behind it. |
| -type servingProc struct { |
| +// genServingProc is a generation child with a real socket server behind it. |
| +// Plan 1 Task 4 already declares a servingProc test type in this package |
| +// (canonlink_test.go, wrapping a Proc and an *http.Server), so this one has |
| +// its own name. |
| +type genServingProc struct { |
| *fakeProc |
| stop func() |
| } |
| -func (p *servingProc) Stop(ctx context.Context) error { |
| +func (p *genServingProc) Stop(ctx context.Context) error { |
| p.stop() |
| return p.fakeProc.Stop(ctx) |
| } |
| @@ -5581,7 +5754,7 @@ git commit -m "zdd plan 3 task 12: agent restart mid-swap and the never-healthy |
| - Create: `internal/activator/swap_sim_test.go`, `internal/activator/testdata/swapsim/mutate.sh` |
| **Interfaces:** |
| -- Consumes: everything above, plus the `// swapsim:M1`…`M6` markers placed in Tasks 2, 3, 4 and 6. |
| +- Consumes: everything above, plus the `// swapsim:M1`…`M7` markers placed in Tasks 2, 3, 4 and 6 (M7 is in Task 2's `stopMember`). |
| - Produces: `TestSwapInvariants` (seeds: `SWAP_SIM_SEEDS`, default 100; replay one with `SWAP_SIM_SEED=<n>`); `mutate.sh`. |
| The invariants (spec §12), checked on every event: |
| @@ -6139,9 +6312,10 @@ cp -a "$ROOT/go.mod" "$ROOT/go.sum" "$ROOT/internal" "$ROOT/launchctx" "$SCRATCH |
| MUTANTS='M1|activator.go|swapsim:M1|s/a\.joinOps\(e\)/true/ |
| M2|swap.go|swapsim:M2|s/a\.addMemberLocked\(e, retiring\)/_ = retiring/ |
| M3|activator.go|swapsim:M3|s/e\.pin != nil/false/ |
| -M4|opset.go|swapsim:M4|s/proven := a\.stopMember\(m\)/proven := true; go a.stopMember(m)/ |
| +M4|opset.go|swapsim:M4|s/proven := a\.stopMember\(e, m\)/proven := true; go a.stopMember(e, m)/ |
| M5|swapadmit.go|swapsim:M5|s/ \|\| e\.state != actAwake// |
| -M6|activator.go|swapsim:M6|s/if a\.cfg\.Swap \{/if false {/' |
| +M6|activator.go|swapsim:M6|s/if a\.gens \{/if false {/ |
| +M7|opset.go|swapsim:M7|s/return a\.genChildEmpty\(e, m\)/return true/' |
| fail=0 |
| while IFS='|' read -r name file marker sub; do |
| src="$SCRATCH/tree/$A/$file" |
| @@ -6177,13 +6351,14 @@ Then run `chmod +x internal/activator/testdata/swapsim/mutate.sh`. |
| - [ ] **Step 6: Run the mutation checks** |
| Run: `uptime && mkdir -p /tmp/k && nice -n 19 internal/activator/testdata/swapsim/mutate.sh 300` |
| -Expected: six `CAUGHT` lines and exit status 0. Each mutant has a deterministic witness: |
| +Expected: seven `CAUGHT` lines and exit status 0. Each mutant has a deterministic witness: |
| - M1: `TestSwapWitnessBarrierJoinsBeforeTheLease`, `TestSwapWitnessAgentRestartMidSwap` |
| - M2: `TestSwapWitnessAgentRestartMidSwap` |
| - M3: `TestSwapWitnessHoldExecsTheStagedBuild` |
| - M4: `TestSwapWitnessBarrierJoinsBeforeTheLease` |
| - M5: `TestSwapWitnessNoAdmissionOntoAStoppingEntry` |
| - M6: `TestSwapWitnessShutdownJoinsAWakingEntry` |
| +- M7: `TestSwapWitnessMemberStopProvesTheGenerationChildEmpty` |
| The sweep count is reported separately. Record both in the branch description. |