| index 566430113..0d954a5de 100644 |
| --- a/docs/superpowers/plans/2026-10-07-instance-settings-4-root-applies.md |
| +++ b/docs/superpowers/plans/2026-10-07-instance-settings-4-root-applies.md |
| @@ -468,7 +468,17 @@ func TestTargetRules(t *testing.T) { |
| TargetDecision{Version: "v3", Reason: ReasonAdopt, Evidence: EvidencePointer, Bake: true}}, |
| {"pin: the pinned build, a record, no bake", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1"}, Installed: "v3"}, |
| TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}}, |
| - {"pin outranks manual and a request", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesManual, Pin: "v1", InstallRequest: 5}}, |
| + // Astra I3 #6: the request is still resolved at the start of the pass, |
| + // pin or no pin, onto the head eligible now; the pin decides what runs. |
| + {"pin outranks manual and a request, which still resolves", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesManual, Pin: "v1", InstallRequest: 5}}, |
| + TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord, Resolve: &ResolvedRequest{ID: 5, Target: "v3"}}}, |
| + {"pin, request already resolved to T: the pin runs, T is kept for later", TargetInput{Pointer: head("v4"), Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 2}, |
| + Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v3"}, Completed: 1}}, |
| + TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}}, |
| + {"pin, the request's T failed: completes failed under the pin", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 2}, |
| + Consumed: &Consumed{Resolved: &ResolvedRequest{ID: 2, Target: "v9"}, Completed: 1}, Failed: failedV9}, |
| + TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord, Fail: 2}}, |
| + {"pin, request waiting on bake: unresolved", TargetInput{Pointer: head("v3"), HeadHeld: "held 20h", Settings: UpdateSettings{Updates: UpdatesAuto, Pin: "v1", InstallRequest: 2}}, |
| TargetDecision{Version: "v1", Reason: ReasonPin, Evidence: EvidenceRecord}}, |
| {"pending request, head eligible: resolves to the head", TargetInput{Pointer: head("v3"), Settings: UpdateSettings{Updates: UpdatesManual, InstallRequest: 2}, |
| Build: known(true, &BuildState{LastExecuted: "v1", Completed: 1}), Installed: "v1"}, |
| @@ -655,43 +665,60 @@ func Target(in TargetInput) TargetDecision { |
| state := in.Build.State |
| wm := Watermark(in.Consumed, state) |
| |
| - // Rule 1: a pin. A person chose it, so bake does not apply. |
| - if u.Pin != "" { |
| - if failed(u.Pin) { |
| - return TargetDecision{Reason: ReasonFailed, Detail: u.Pin} |
| - } |
| - return TargetDecision{Version: u.Pin, Reason: ReasonPin, Evidence: EvidenceRecord, Detail: u.Pin} |
| - } |
| - |
| - // Rule 2: a RESOLVED request above the watermark. A completed request |
| - // never enters here again. |
| - var failedReq uint64 |
| + // Request bookkeeping comes first, before any selection rule, pins |
| + // included (§4.3 rule 2: "resolved at the start of every pass"; Astra I3 |
| + // #6). A pending R is resolved onto the head eligible now; a resolved R |
| + // whose T failed completes failed. A pin then decides what runs, and the |
| + // resolution waits, so clearing the pin later installs this T, not a |
| + // head that moved on. A completed request never enters here again. |
| + var ( |
| + resolve *ResolvedRequest |
| + failedReq uint64 |
| + reqTarget string // a resolved T, not failed, above the watermark |
| + ) |
| if u.InstallRequest > wm { |
| if res := resolvedFor(in.Consumed, state, u.InstallRequest); res != nil { |
| - if !failed(res.Target) { |
| - ev := EvidenceRecord |
| - if res.Target == head { |
| - ev = EvidencePointer |
| - } |
| - return TargetDecision{Version: res.Target, Reason: ReasonRequest, Evidence: ev, Detail: res.Target} |
| + if failed(res.Target) { |
| + failedReq = res.ID // completes failed, once; the rules below decide what runs |
| + } else { |
| + reqTarget = res.Target |
| } |
| - // T failed: the request completes as failed, once, and the next |
| - // rule decides what runs. |
| - failedReq = res.ID |
| } else if head != "" && (in.HeadHeld == "" || u.InstallFastTrack) && !failed(head) { |
| - return TargetDecision{Version: head, Reason: ReasonRequest, Evidence: EvidencePointer, |
| - Resolve: &ResolvedRequest{ID: u.InstallRequest, Target: head}, Detail: head} |
| + resolve = &ResolvedRequest{ID: u.InstallRequest, Target: head} |
| + reqTarget = head |
| } |
| // Unresolved (the head is baking here): it blocks nothing. |
| } |
| // A completed request whose target failed after it first ran (a later |
| // health, settle or hold failure) is recorded completed-failed, once, |
| - // without reopening it (Astra I1 #10, §4.3 rule 2). |
| + // without reopening it (Astra I1 #10, §4.3 rule 2); also before the pin. |
| if failedReq == 0 && u.InstallRequest != 0 && u.InstallRequest <= wm && !completedFailed(in.Consumed, state, wm) { |
| if res := resolvedFor(in.Consumed, state, wm); res != nil && failed(res.Target) { |
| failedReq = res.ID |
| } |
| } |
| + carry := func(d TargetDecision) TargetDecision { |
| + d.Resolve, d.Fail = resolve, failedReq |
| + return d |
| + } |
| + |
| + // Rule 1: a pin. A person chose it, so bake does not apply. |
| + if u.Pin != "" { |
| + if failed(u.Pin) { |
| + return carry(TargetDecision{Reason: ReasonFailed, Detail: u.Pin}) |
| + } |
| + return carry(TargetDecision{Version: u.Pin, Reason: ReasonPin, Evidence: EvidenceRecord, Detail: u.Pin}) |
| + } |
| + |
| + // Rule 2: a resolved request above the watermark (this pass's resolution |
| + // included). |
| + if reqTarget != "" { |
| + ev := EvidenceRecord |
| + if reqTarget == head { |
| + ev = EvidencePointer |
| + } |
| + return carry(TargetDecision{Version: reqTarget, Reason: ReasonRequest, Evidence: ev, Detail: reqTarget}) |
| + } |
| |
| // Rule 3: held. |
| majorHeld := u.Major != nil && !tagInMajor(in.HeadTag, *u.Major) |
| @@ -1099,7 +1126,7 @@ func (b *buildStates) write(ctx context.Context, host string, s release.BuildSta |
| |
| Which routes carry a build state: activator-managed exec instances (`Hibernate`, no `Unit`, not platform); the settings apply only to them (plan 3 refuses named units and other kinds). If Task 0 Step 4 found the claimed wake leasing a different prefix, `prefix` consults the claim holder's location for hosts with an active claim and the hibernate prefix otherwise; write that branch here with the function Task 0 named. |
| |
| -- [ ] **Step 4: Refresh after reconcile.** In `agent.go`'s fast-converge leg, after the `reconcileSerialized(…)` call and its error log, add `if bs != nil { bs.refreshAll(ctx) }`, where `bs` is built once in `runAgent` with `newBuildStates(reg, release.OptDir())` (log and continue on error) and passed to the loop the way `svc` is. Also call it once after the hourly `adoptLoop`'s reconcile if that loop reconciles (it does not on main; check). |
| +- [ ] **Step 4: Refresh after reconcile.** In `agent.go`'s fast-converge leg, after the `reconcileSerialized(…)` call and its error log, add `if bs != nil { bs.refreshAll(ctx) }`, where `bs` is built once in `runAgent` with `bs, bsErr := newBuildStates(reg, release.OptDir())` and passed to the loop the way `svc` is. On `bsErr != nil` the agent logs it and keeps running, but **never** with the nil hook: Task 5 Step 7 installs a refusing `BeforeExec` for that case, and Task 10's report says the box does not enforce settings (Astra I3 #1). `nil, nil` (no replica bucket configured) is the only case that leaves the hook nil. Also call it once after the hourly `adoptLoop`'s reconcile if that loop reconciles (it does not on main; check). |
| |
| - [ ] **Step 5: Run the package.** Run: `cd $W && go build ./... && TMPDIR=/tmp/k go test -race ./internal/host/ -count=1`. Expected: PASS. |
| |
| @@ -1119,7 +1146,7 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| |
| **Files:** |
| - Create: `internal/activator/beforeexec.go`, `internal/activator/beforeexec_test.go` |
| -- Modify: `internal/activator/activator.go` (`Service`; `doWake` between the restore (step 2) and the seed (step 3) ~L1186, the seed call, and the spawn arms), `internal/activator/claimwake.go` (`startAdmitted`'s `beforeStart`, Step 5b), `internal/activator/backend_exec.go` (`LiveIdentity`, `StartExe`, `SeedExe`; `seedChild` and `seedUnit` gain `exe`; `startRole`'s generation-0 branch), `internal/activator/backend_unit.go` (`SplitBackend` forwards), and the zdd spawn points listed in Step 6 |
| +- Modify: `internal/activator/activator.go` (`Service`; `doWake` between the restore (step 2) and the seed (step 3) ~L1186, the seed call, and the spawn arms), `internal/activator/claimwake.go` (`startAdmitted`'s `beforeStart`, Step 5b), `internal/activator/backend_exec.go` (`LiveIdentity`, `StartExe`, `SeedExe`; `seedChild` and `seedUnit` gain `exe`; `startRole`'s generation-0 branch), `internal/activator/backend_unit.go` (`SplitBackend.identityFor`, per route through `pick`), and the zdd spawn points listed in Step 6 |
| - Modify: `internal/instancectl/server.go` (the `/start` parser admits `exe` for the generation-0 run role; `handleSeed` parses and validates `exe`; `parseStagedExe`), `internal/instancectl/client.go` (`StartExe`, `SeedExe`), `internal/instancectl/stagedexe_test.go` (Step 4a) |
| - Modify: `internal/host/buildstate.go` (`beforeExec`), `internal/host/agent.go` (wire `svc.BeforeExec`) |
| |
| @@ -1336,6 +1363,36 @@ type identityBackend interface { |
| StartExe(ctx context.Context, rt registry.Route, id release.StagedIdentity) (Proc, string, error) |
| SeedExe(ctx context.Context, rt registry.Route, id release.StagedIdentity) error |
| } |
| + |
| +// identityPicker is a composed backend that answers per route (Astra I3 |
| +// #3): SplitBackend.identityFor goes through pick, so a unit-backed route |
| +// gets nil and keeps today's Start and Seed. |
| +type identityPicker interface { |
| + identityFor(rt registry.Route) identityBackend |
| +} |
| + |
| +// identityFor is the backend that can pin an executable for rt, or nil. |
| +// Never a type assertion on the composed backend alone: production's |
| +// SplitBackend serves unit-backed routes too. |
| +func identityFor(b Backend, rt registry.Route) identityBackend { |
| + if p, ok := b.(identityPicker); ok { |
| + return p.identityFor(rt) |
| + } |
| + ib, _ := b.(identityBackend) |
| + return ib |
| +} |
| +``` |
| + |
| +In `internal/activator/backend_unit.go`, beside `pick`: |
| + |
| +```go |
| +// identityFor is the identity-pinned half for rt's backend: the exec side's |
| +// for an exec row, nil for a unit row (Astra I3 #3). SplitBackend does not |
| +// implement identityBackend itself. |
| +func (b *SplitBackend) identityFor(rt registry.Route) identityBackend { |
| + ib, _ := b.pick(rt).(identityBackend) |
| + return ib |
| +} |
| ``` |
| |
| Add `registry` and `release` to the imports. |
| @@ -1349,7 +1406,7 @@ and to `Service`: |
| BeforeExec func(ctx context.Context, host, build string) error |
| ``` |
| |
| -- [ ] **Step 4: The exec backend execs an identity.** In `internal/activator/backend_exec.go`, add to `ExecBackend` (and forward on `SplitBackend`, as zdd-4 T8 forwards `StartWorkerStaged`): |
| +- [ ] **Step 4: The exec backend execs an identity.** In `internal/activator/backend_exec.go`, add to `ExecBackend` (`SplitBackend` does **not** forward these; it answers `identityFor` per route, Step 3, Astra I3 #3): |
| - `LiveIdentity(ctx, rt) (release.StagedIdentity, error)`: today's `b.ensureLiveLink(ctx, rt.Host)` (`backend_exec.go:1106`), unchanged, so a missing link goes through the `Materialize` hook and its root-stage relay; then `release.ResolveLive(b.OptDir, rt.Host)`, once (Astra I2 #6: the identity is captured only after materializing, so a cold default instance still wakes). |
| - `StartExe(ctx, rt, id)`: `release.VerifyStaged(id)` first (a refusal is a failed start, never a fallback to the link). The flat path calls zdd-3's `spawnChildFrom(ctx, rt, sock, 0, id.StagedPath, tier)` then `startChildAt`'s health tail. The nested path calls `spawnUnitFrom(ctx, rt, sock, 0, &instancectl.StagedExe{Path: id.StagedPath, Version: id.Version, Digest: id.Digest}, tier)` then `startUnitAt`'s health tail; Step 4a makes the generation-0 run role honour that executable. `sock` and `tier` are what `Start` derives today (the canonical socket; `b.TierOf(rt, id.Version)`). It returns `id.Version` as the spawned version. |
| - `SeedExe(ctx, rt, id)`: `release.VerifyStaged(id)`, then by layout. Flat: `seedChild` with `id.StagedPath` where it passes `b.liveLink(host)` (`seedChild` gains an `exe string` parameter; `Seed` passes the live link, unchanged). Nested: `seedUnit` gains `exe *instancectl.StagedExe` and calls `b.Control.SeedExe(ctx, rt.Host, tier.MemoryMax, *exe)` when it is set (Step 4a); `Seed` passes nil. |
| @@ -1405,7 +1462,7 @@ func TestTheSeedUnitBindsAValidatedStagedExe(t *testing.T) { |
| // the lease taken in step 1), and the only file this wake runs. |
| // A backend that cannot pin an executable (UnitBackend) keeps today's |
| // calls and records nothing. |
| - ib, _ := a.backend.(identityBackend) |
| + ib := identityFor(a.backend, rt) |
| var id release.StagedIdentity |
| if ib != nil { |
| var idErr error |
| @@ -1442,7 +1499,48 @@ func (a *Service) wakeIdentity(ctx context.Context, ib identityBackend, e *actEn |
| } |
| ``` |
| |
| -Then: in step 3, when the backend is an `identityBackend`, replace `a.backend.Seed(ctx, rt)` with `ib.SeedExe(ctx, rt, id)`; in step 4, replace the instance arm's `proc, spawnVer, err = a.backend.Start(ctx, rt)` with `proc, spawnVer, err = ib.StartExe(ctx, rt, id)` (flat and nested alike: `StartExe` dispatches by layout as `Start` does), and the worker arm's `a.startWorker(ctx, rt, env, nil)` with `a.startWorker(ctx, rt, env, &id)`. The planned and pinned arms already exec `plan.Boot`/`*e.pin` through `startPinned`, which now skips its own write for the recorded build. A backend that is not an `identityBackend` (`UnitBackend`: systemd-unit routes, which plan 3 refuses settings on) keeps today's calls, and the wake records nothing for it, because nothing there could exec a pinned file. |
| +Then: in step 3, when `ib != nil`, replace `a.backend.Seed(ctx, rt)` with `ib.SeedExe(ctx, rt, id)`; in step 4, replace the instance arm's `proc, spawnVer, err = a.backend.Start(ctx, rt)` with `proc, spawnVer, err = ib.StartExe(ctx, rt, id)` (flat and nested alike: `StartExe` dispatches by layout as `Start` does), and the worker arm's `a.startWorker(ctx, rt, env, nil)` with `a.startWorker(ctx, rt, env, &id)`. The planned and pinned arms already exec `plan.Boot`/`*e.pin` through `startPinned`, which now skips its own write for the recorded build. A route whose backend cannot pin an executable (`identityFor` nil: a unit-backed route on production's `SplitBackend`, systemd-unit routes, which plan 3 refuses settings on) keeps today's calls, and the wake records nothing for it, because nothing there could exec a pinned file. |
| + |
| +Add to Step 1's tests, on the production shape (`activator.New` over a `SplitBackend`, as `agent.go:382` builds it): |
| + |
| +```go |
| +// Astra I3 #3: identity capability is per route. On a SplitBackend whose |
| +// exec half pins executables, a unit-backed route still wakes by today's |
| +// Start and Seed, records nothing, and never reaches StartExe; an exec row on |
| +// the same service takes the identity path. |
| +func TestAUnitRouteOnASplitBackendKeepsTodaysWake(t *testing.T) { |
| + exec, unit := &fakeBackend{liveVersion: "v1"}, &fakeBackend{liveVersion: "v1"} |
| + // struct{ Backend } hides the unit fake's identity methods, as UnitBackend has none. |
| + split := &SplitBackend{Exec: exec, Unit: struct{ Backend }{unit}} |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + a := New(Config{Bucket: "test-bucket", Idle: time.Hour}, split, reg) // as agent.go builds it |
| + var l execLog |
| + a.BeforeExec = l.hook |
| + ur := testRoute("u.example.com") |
| + ur.Backing = registry.BackingUnit |
| + done, err := a.beginRequest(context.Background(), ur) |
| + if err != nil { |
| + t.Fatalf("a unit route's wake failed on the split backend: %v", err) |
| + } |
| + done() |
| + if unit.liveStarts() != 1 || exec.startCount() != 0 || len(l.calls) != 0 { |
| + t.Fatalf("unit live starts %d, exec starts %d, records %v; want today's unit wake, nothing recorded", unit.liveStarts(), exec.startCount(), l.calls) |
| + } |
| + done, err = a.beginRequest(context.Background(), testRoute("e.example.com")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + done() |
| + if exec.lastExeVersion() != "v1" || len(l.calls) != 1 { |
| + t.Fatalf("the exec row did not take the identity path: %q, %v", exec.lastExeVersion(), l.calls) |
| + } |
| +} |
| +``` |
| + |
| +With I2's `a.backend.(identityBackend)` (SplitBackend forwarding the methods), the unit route is sent to the exec fake's `LiveIdentity`/`StartExe` and the first check fails. |
| |
| Add to Step 1's tests: |
| |
| @@ -1511,7 +1609,7 @@ func TestAClaimedStartRecordsBeforeEachExec(t *testing.T) { |
| - zdd-3 `startPinned(ctx, rt, pin, …)`: `if err := a.beforeExec(ctx, rt.Host, pin.Build); err != nil { return nil, "", pin, err }`. |
| - zdd-3 `spawnSuccessor(ctx, gb, e, rt, work, g)`: `if err := a.beforeExec(ctx, rt.Host, g.Build); err != nil { return g, nil, err }`. Its caller in `swapOverlap` already maps a spawn error to the abandoned or failed result the zdd plans define; check that an `errBuildStateUnwritten` maps to `SwapAbandoned` (not a build failure, which would write a failure record) and add `errors.Is(spawnErr, errBuildStateUnwritten)` to that mapping if not. |
| - zdd-3 `startAdmitted`'s fallback arm (`if policy.Fallback != nil && !ready && …`): before the fallback's spawn, `if err := a.beforeExec(ctx, rt.Host, policy.Fallback.Build); err != nil { … return the same error the arm returns for a failed fallback spawn … }`. |
| - - zdd-4 `startWorker(ctx, rt, env, onto)`: if `onto == nil`, materialize and resolve first, the same order as `wakeIdentity` (Astra I2 #6): `if ib, ok := a.backend.(identityBackend); ok { id, err := ib.LiveIdentity(ctx, rt); if err != nil { return nil, release.StagedIdentity{}, err }; onto = &id }`; then `build := ""; if onto != nil { build = onto.Version }; if err := a.beforeExec(ctx, rt.Host, build); err != nil { return nil, release.StagedIdentity{}, err }`, and pass `onto` on. (Passing the resolution as `onto` makes the exec use exactly the build recorded.) |
| + - zdd-4 `startWorker(ctx, rt, env, onto)`: if `onto == nil`, materialize and resolve first, the same order as `wakeIdentity` (Astra I2 #6): `if ib := identityFor(a.backend, rt); ib != nil { id, err := ib.LiveIdentity(ctx, rt); if err != nil { return nil, release.StagedIdentity{}, err }; onto = &id }`; then `build := ""; if onto != nil { build = onto.Version }; if err := a.beforeExec(ctx, rt.Host, build); err != nil { return nil, release.StagedIdentity{}, err }`, and pass `onto` on. (Passing the resolution as `onto` makes the exec use exactly the build recorded.) |
| |
| zdd-5's `spawnPlanned`, `spawnPlannedWorker` and `ReplaceWorker` reach the exec only through `startPinned` and `startWorker`, so they need nothing of their own; the tests in Step 1 prove it. |
| |
| @@ -1563,7 +1661,47 @@ func (b *buildStates) beforeExec(ctx context.Context, host, build string) error |
| } |
| ``` |
| |
| -In `runAgent`, where the activator `Service` is built, set `svc.BeforeExec = bs.beforeExec` when `bs != nil`. A box with no replica bucket runs no hibernating instances and leaves it nil. |
| +and the hook's choice: |
| + |
| +```go |
| +// buildStateHook is Service.BeforeExec for this box (settings spec §4.2, |
| +// Astra I3 #1). Only a box with no replica bucket configured (bs nil, no |
| +// error) keeps the nil hook: it runs no hibernating instance, so it has no |
| +// build state. A box whose bucket is configured but whose build-state |
| +// service failed to start refuses every exec: the write before execution is |
| +// mandatory, and "could not record" never means "run unrecorded". |
| +func buildStateHook(bs *buildStates, initErr error) func(ctx context.Context, host, build string) error { |
| + if initErr != nil { |
| + return func(context.Context, string, string) error { |
| + return fmt.Errorf("the build state service did not start on this box (%v); restart the agent once it is fixed", initErr) |
| + } |
| + } |
| + if bs == nil { |
| + return nil |
| + } |
| + return bs.beforeExec |
| +} |
| +``` |
| + |
| +In `runAgent`, where the activator `Service` is built, set `svc.BeforeExec = buildStateHook(bs, bsErr)` (log `bsErr` once, as an operator line). Add to `internal/host/buildstate_test.go`: |
| + |
| +```go |
| +// Astra I3 #1: an initialization failure never leaves the nil hook. |
| +func TestAFailedBuildStateStartRefusesEveryExec(t *testing.T) { |
| + hook := buildStateHook(nil, errors.New("s3: no credentials")) |
| + if hook == nil { |
| + t.Fatal("an initialization failure installed the nil hook: execs would run unrecorded") |
| + } |
| + if err := hook(context.Background(), "a.example.com", "v1"); err == nil { |
| + t.Fatal("the refusing hook let an exec through") |
| + } |
| + if buildStateHook(nil, nil) != nil { |
| + t.Fatal("a box with no replica bucket must keep today's nil hook") |
| + } |
| +} |
| +``` |
| + |
| +and to `internal/activator/beforeexec_test.go`, `TestARefusingHookStartsNothing`: a `newTestService` with `a.BeforeExec` returning an error; a wake fails with `errBuildStateUnwritten` and `b.startCount()` stays 0. With I2's `if bs != nil` wiring, the first test fails (the hook is nil). |
| |
| - [ ] **Step 8: Run the packages.** Run: `cd $W && go build ./... && TMPDIR=/tmp/k go test -race ./internal/activator/ ./internal/host/ ./internal/instancectl/ -count=1`. Expected: PASS, every zdd test included, with Step 4a's one changed assertion in `TestSidecarStartMountsTheValidatedStagedExe`. |
| |
| @@ -2044,9 +2182,11 @@ func settingsTarget(ctx context.Context, s Store, optDir string, bake *FirstSeen |
| fmt.Printf(" %s: recording the update decision: %v; nothing installed\n", rt.Host, err) |
| return "", dec, true |
| } |
| - } else if dec.Resolve != nil { |
| + } else if dec.Resolve != nil && dec.Reason == ReasonRequest { |
| return "", dec, true // only root resolves; the next root pass will |
| } |
| + // (An agent pass under a pin carries a Resolve it does not write; the |
| + // pin's install does not depend on it, so it goes ahead.) |
| if dec.Version == "" || dec.Version == cur { |
| if dec.Version == "" { |
| fmt.Printf(" %s: %s: nothing installed\n", rt.Host, dec.Reason) |
| @@ -2161,13 +2301,20 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| |
| **Files:** |
| - Create: `internal/activator/wakecheck.go`, `internal/activator/wakecheck_test.go` |
| -- Modify: `internal/activator/activator.go` (`Service`; `doWake` and `claimwake.go` `doClaimWake` before Task 5's `beforeExec` block), `internal/host/buildstate.go` (`heldTarget`), `internal/host/agent.go` |
| +- Modify: `internal/activator/activator.go` (`Service`; `doWake` and `claimwake.go` `doClaimWake` before Task 5's `beforeExec` block), `internal/host/buildstate.go` (`heldTarget`, `retained`), `internal/host/agent.go` |
| |
| **Interfaces:** |
| -- Consumes: `release.ReadConsumed`, `ParseBuildStateCopy`, `ParseUpdateSettings`, `Watermark`, `ReasonFailed`; `Service.liveVer`, `Service.Routes`; zdd-3 `actEntry.pin` (read only: the check never sets it); zdd-5 `Planner`, `WakePlan`, `planActivator`; Task 5's `LiveIdentity` and step 2b; `stageRequester` (`agent.go:1357`). |
| -- Produces: `Service.HeldTarget func(host string) (build string, held bool, unknown bool)`; `Service.RequestStage func(host, build string)`; `func (a *Service) wakeCheck(ctx context.Context, host string) (string, error)`; `func heldIdentityOK(target string, id release.StagedIdentity, plan WakePlan, rt registry.Route) bool`; `var errBuildUnknown, errStaleLink error`; host `func (b *buildStates) heldTarget(host string) (string, bool, bool)`. |
| +- Consumes: `release.ReadConsumed`, `ReadRefusal`, `ReadStagedManifest`, `VersionPath`, `FileSHA256`, `RouteAccount`, `ParseBuildStateCopy`, `ParseUpdateSettings`, `Watermark`, `ReasonFailed`; `Service.liveVer`, `Service.Routes`; zdd-3 `actEntry.pin` (read only: the check never sets it); zdd-5 `Planner`, `WakePlan`, `planActivator`; Task 5's `LiveIdentity`, `wakeIdentity` and step 2b; `stageRequester` (`agent.go:1357`). |
| +- Produces: `type HeldWake struct{ Target string; Held, Unknown, Failed bool; Staged *release.StagedIdentity }`; `Service.HeldTarget func(host string) HeldWake`; `Service.RequestStage func(host, build string)`; `func (a *Service) wakeCheck(ctx context.Context, host string, direct bool) (string, *release.StagedIdentity, error)`; `func heldIdentityOK(target string, id release.StagedIdentity, plan WakePlan, rt registry.Route) bool`; `var errBuildUnknown, errStaleLink, errTargetFailed error`; host `func (b *buildStates) heldTarget(host string) activator.HeldWake`, `func (b *buildStates) retained(rt registry.Route, v string) *release.StagedIdentity`; `buildStates.bakeDir`. |
| |
| -§4.3 rule 3: before it spawns a held instance, the agent compares the installed build with its target, in root's rule order (a pin, then a pending request's T, then `last_executed`; Astra I2 #7). If they differ it never runs the installed build: it touches the stage relay and waits, within the wake's own deadline, for root to install the target. It never pins the target itself (Astra I2 #4): root's `Target` applies the failure book before it installs anything, and the wake then goes through the scheduler's failure-aware decision (zdd-5's `Planner`, `decideWake`: `HeldDown` refuses, `Blocks` boots the good build or refuses) exactly as any other wake does. When root's own decision for the target is `failed`, the agent does not wait for an install root will not make: the wake is not held and the failure-aware planner decides. After the identity is chosen (Task 5), the wake refuses unless it is the target or the planner's recovery boot. An unknown build fails the wake with `settings.box.reason.unknown`. |
| +§4.3 rule 3: before it spawns a held instance, the agent compares the installed build with its target, in root's rule order (a pin, then a pending request's T, then `last_executed`; Astra I2 #7), and never runs a different installed build. **Settings work with `CARLOS_SWAP` off (spec rev 11 §4.2), where zdd-5's `Planner` is nil, so the wake check has its own small admission rule that needs no planner (Astra I3 #2, #5, I2-4):** |
| + |
| +1. **A target root reports failed is never run.** Root's last decision for the host is `failed` naming the target, or the box's refusal record (`release.ReadRefusal(bakeDir, host)`, the record root writes when a build did not come up) names it. With no planner the wake is refused (`errTargetFailed`), even when the failed target is still the live link. With a planner the wake is not held and zdd-5's failure-aware `decideWake` chooses: recovery onto the good build, or a refusal. |
| +2. **A retained, verified staged copy of the target is run directly** when there is no planner: the agent finds the target's staged executable on this box whose sha256 equals its staged manifest's (the recovery check `stageAuthorized` uses), and Task 5's wake records that identity first and then execs exactly that file (`wakeIdentity` takes it ahead of `LiveIdentity`). No root repoint is needed. The claimed path (`doClaimWake`) cannot inject an executable (its launcher execs the claim inputs' file), so it uses rule 3. |
| +3. **Otherwise ask root and wait**: touch the stage relay and wait, within the wake's own deadline, for root to install the target. |
| +4. **When a planner exists, defer to it** for what boots after root's install (I2 #4): the check never sets `e.pin`, and the identity must be the target or the planner's recovery boot (`heldIdentityOK`). |
| + |
| +An unknown build fails the wake with `settings.box.reason.unknown`. |
| |
| - [ ] **Step 1: Write the failing tests.** Create `internal/activator/wakecheck_test.go`, on `newTestService`: |
| |
| @@ -2181,11 +2328,15 @@ import ( |
| "time" |
| ) |
| |
| +func heldOn(v string) func(string) HeldWake { |
| + return func(string) HeldWake { return HeldWake{Target: v, Held: true} } |
| +} |
| + |
| // Spec §8 "Returning residence": the wake never runs a stale link. |
| func TestAHeldWakeNeverRunsAStaleLink(t *testing.T) { |
| a, b := newTestService(t) |
| b.liveVersion = "v3" // an old live link left on this box |
| - a.HeldTarget = func(string) (string, bool, bool) { return "v1", true, false } |
| + a.HeldTarget = heldOn("v1") |
| var asked []string |
| a.RequestStage = func(host, build string) { |
| asked = append(asked, build) |
| @@ -2204,7 +2355,7 @@ func TestAHeldWakeNeverRunsAStaleLink(t *testing.T) { |
| func TestAHeldWakeWaitsOnlyWithinItsBound(t *testing.T) { |
| a, b := newTestService(t) |
| b.liveVersion = "v3" |
| - a.HeldTarget = func(string) (string, bool, bool) { return "v1", true, false } |
| + a.HeldTarget = heldOn("v1") |
| a.RequestStage = func(string, string) {} |
| ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) |
| defer cancel() |
| @@ -2218,7 +2369,7 @@ func TestAHeldWakeWaitsOnlyWithinItsBound(t *testing.T) { |
| |
| func TestAnUnknownBuildNeverWakes(t *testing.T) { |
| a, b := newTestService(t) |
| - a.HeldTarget = func(string) (string, bool, bool) { return "", true, true } |
| + a.HeldTarget = func(string) HeldWake { return HeldWake{Held: true, Unknown: true} } |
| if _, err := a.beginRequest(context.Background(), testRoute("one.example.com")); !errors.Is(err, errBuildUnknown) { |
| t.Fatalf("err = %v", err) |
| } |
| @@ -2229,7 +2380,7 @@ func TestAnUnknownBuildNeverWakes(t *testing.T) { |
| |
| func TestAnAutoInstanceIsNotChecked(t *testing.T) { |
| a, b := newTestService(t) |
| - a.HeldTarget = func(string) (string, bool, bool) { return "", false, false } |
| + a.HeldTarget = func(string) HeldWake { return HeldWake{} } |
| done, err := a.beginRequest(context.Background(), testRoute("one.example.com")) |
| if err != nil { |
| t.Fatal(err) |
| @@ -2240,6 +2391,59 @@ func TestAnAutoInstanceIsNotChecked(t *testing.T) { |
| } |
| } |
| |
| +// Astra I3 #2: with CARLOS_SWAP off there is no planner. A target root |
| +// reports failed is never run, even while it is still the live link. |
| +func TestWithNoPlannerAFailedTargetIsRefusedAtTheLiveLink(t *testing.T) { |
| + a, b := newTestService(t) // Planner nil, as with CARLOS_SWAP unset |
| + b.liveVersion = "v9" // the failed build is still installed |
| + a.HeldTarget = func(string) HeldWake { return HeldWake{Target: "v9", Held: true, Failed: true} } |
| + if _, err := a.beginRequest(context.Background(), testRoute("one.example.com")); !errors.Is(err, errTargetFailed) { |
| + t.Fatalf("err = %v, want errTargetFailed", err) |
| + } |
| + if b.startCount() != 0 { |
| + t.Fatal("a build root reported failed ran again") |
| + } |
| +} |
| + |
| +// Astra I3 #5: with no planner, a retained, verified staged copy of the |
| +// target runs directly, recorded first; root's repoint is not needed. |
| +func TestWithNoPlannerARetainedCopyRunsWithoutARepoint(t *testing.T) { |
| + a, b := newTestService(t) |
| + b.liveVersion = "v3" // stale B installed |
| + retained := release.StagedIdentity{Version: "v1", StagedPath: "/opt/carlos/versions/bab/v1/blog", Digest: "sha-v1"} |
| + a.HeldTarget = func(string) HeldWake { return HeldWake{Target: "v1", Held: true, Staged: &retained} } |
| + a.RequestStage = func(string, string) { t.Error("asked root to repoint, with the retained copy on the box") } |
| + var l execLog |
| + a.BeforeExec = l.hook |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + done, err := a.beginRequest(ctx, testRoute("one.example.com")) |
| + if err != nil { |
| + t.Fatalf("the wake waited for root instead of running the retained copy: %v", err) |
| + } |
| + done() |
| + if b.lastExe() != retained || len(l.calls) != 1 || l.calls[0] != "one.example.com@v1" { |
| + t.Fatalf("ran %+v, recorded %v; want the retained v1, recorded first", b.lastExe(), l.calls) |
| + } |
| +} |
| + |
| +// With a planner, a failed target is the planner's call (I2 #4): the wake is |
| +// not refused by the settings rule, and the planner's recovery boots. |
| +func TestWithAPlannerAFailedTargetDefersToIt(t *testing.T) { |
| + good := release.StagedIdentity{Version: "v0", StagedPath: "/opt/carlos/versions/v0/blog", Digest: "sha-v0"} |
| + a, b, _ := planActivator(t, WakePlan{Boot: good}) |
| + b.liveVersion = "v9" |
| + a.HeldTarget = func(string) HeldWake { return HeldWake{Target: "v9", Held: true, Failed: true} } |
| + done, err := a.beginRequest(context.Background(), testRoute("one.example.com")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + done() |
| + if b.lastStartedVersion() != "v0" { |
| + t.Fatalf("started %q, want the planner's recovery v0", b.lastStartedVersion()) |
| + } |
| +} |
| + |
| // stampRoutes is the registry row the wake re-reads (RouteLookup); root's |
| // repoint stamps Version, as adoption does. |
| type stampRoutes struct { |
| @@ -2273,7 +2477,7 @@ func TestAHeldWakeGoesThroughTheFailureAwarePlan(t *testing.T) { |
| planned = append(planned, rt.Version) |
| return WakePlan{Boot: good} // decideWake's recovery when Blocks(v1) |
| } |
| - a.HeldTarget = func(string) (string, bool, bool) { return "v1", true, false } |
| + a.HeldTarget = heldOn("v1") |
| a.RequestStage = func(host, build string) { b.liveVersion = build; routes.stamp(build) } |
| done, err := a.beginRequest(context.Background(), routes.rt) |
| if err != nil { |
| @@ -2290,7 +2494,7 @@ func TestAHeldWakeGoesThroughTheFailureAwarePlan(t *testing.T) { |
| func TestAHeldWakeRefusesAnIdentityThatIsNotItsTarget(t *testing.T) { |
| a, b := newTestService(t) |
| b.liveVersion = "v3" |
| - a.HeldTarget = func(string) (string, bool, bool) { return "v1", true, false } |
| + a.HeldTarget = heldOn("v1") |
| a.RequestStage = func(string, string) { b.liveVersion = "v1" } |
| b.onLiveIdentity = func() { b.liveVersion = "v2" } // a repoint after the wait |
| if _, err := a.beginRequest(context.Background(), testRoute("one.example.com")); !errors.Is(err, errStaleLink) { |
| @@ -2302,9 +2506,9 @@ func TestAHeldWakeRefusesAnIdentityThatIsNotItsTarget(t *testing.T) { |
| } |
| ``` |
| |
| -`lastStartedVersion()` is Task 5's `lastExeVersion()` (the version of the last `StartExe` or `StartStaged`). Add to `fakeBackend`: `stage(v string)` (records v as staged; `StartStaged` and the I1 shortcut's existence check see it) and `onLiveIdentity func()`, run inside `LiveIdentity` before it resolves. Add `sync`, `registry` and `release` to the imports. |
| +`lastStartedVersion()` is Task 5's `lastExeVersion()` (the version of the last `StartExe` or `StartStaged`); `lastExe()` is the whole last identity `StartExe` was given. Add to `fakeBackend`: `stage(v string)` (records v as staged; `StartStaged` sees it) and `onLiveIdentity func()`, run inside `LiveIdentity` before it resolves. Add `sync`, `registry` and `release` to the imports. Without rule 1 the failed-target test starts v9 from the live link; without rule 2 the retained-copy test calls `RequestStage` and times out. |
| |
| -- [ ] **Step 2: Run them, see them fail.** Run: `cd $W && TMPDIR=/tmp/k go test ./internal/activator/ -run 'HeldWake|UnknownBuildNeverWakes|AutoInstanceIsNotChecked' -count=1`. Expected: FAIL to compile. (`TestAHeldWakeGoesThroughTheFailureAwarePlan` needs zdd-5 T9's `planActivator`, which Task 0 confirms is on the base.) |
| +- [ ] **Step 2: Run them, see them fail.** Run: `cd $W && TMPDIR=/tmp/k go test ./internal/activator/ -run 'HeldWake|UnknownBuildNeverWakes|AutoInstanceIsNotChecked|NoPlanner|WithAPlanner' -count=1`. Expected: FAIL to compile. (`TestAHeldWakeGoesThroughTheFailureAwarePlan` needs zdd-5 T9's `planActivator`, which Task 0 confirms is on the base.) |
| |
| - [ ] **Step 3: Create `internal/activator/wakecheck.go`:** |
| |
| @@ -2323,42 +2527,64 @@ import ( |
| var ( |
| errBuildUnknown = errors.New("build unknown: use Update now or set a pin") |
| errStaleLink = errors.New("the installed build is not this instance's build, and root has not installed it yet") |
| + errTargetFailed = errors.New("this instance's build failed on this box, so it is not started again") |
| ) |
| |
| -// wakeCheck is settings spec §4.3 rule 3's wake half. For a held instance it |
| -// returns the target (a pin, else a pending request's T, else last_executed) |
| -// once root has installed it: it asks root and waits within the wake's own |
| -// deadline. It never pins the target (Astra I2 #4): what boots is still |
| -// chosen by the failure-aware planner, or by the live identity Task 5 |
| -// captures, and checked against the target by heldIdentityOK. "" means the |
| -// instance is not held. An unknown build never wakes. |
| -func (a *Service) wakeCheck(ctx context.Context, host string) (string, error) { |
| +// HeldWake is the agent's answer for a held instance's wake (settings spec |
| +// §4.3 rule 3). |
| +type HeldWake struct { |
| + Target string // the build the instance must run |
| + Held bool // false: root follows the channel for it; today's wake |
| + Unknown bool // rule 3's "build unknown" |
| + Failed bool // root reports Target failed on this box (its decision, or a refusal record) |
| + // Staged is a retained copy of Target on this box whose bytes match its |
| + // staged manifest; nil when there is none. |
| + Staged *release.StagedIdentity |
| +} |
| + |
| +// wakeCheck is settings spec §4.3 rule 3's wake half, with its own admission |
| +// rule so it holds with CARLOS_SWAP off and no planner (Astra I3 #2, #5): |
| +// a failed target is refused (or, with a planner, left to its failure-aware |
| +// decision); with no planner and direct set, a retained verified copy of the |
| +// target is returned to run as is; otherwise root is asked to install the |
| +// target and the wake waits within its own deadline. It never sets e.pin |
| +// (I2 #4). The string is the target ("" when the wake is not held). |
| +func (a *Service) wakeCheck(ctx context.Context, host string, direct bool) (string, *release.StagedIdentity, error) { |
| if a.HeldTarget == nil { |
| - return "", nil |
| + return "", nil, nil |
| } |
| - target, held, unknown := a.HeldTarget(host) |
| - if !held { |
| - return "", nil |
| + h := a.HeldTarget(host) |
| + if !h.Held { |
| + return "", nil, nil |
| } |
| - if unknown || target == "" { |
| - return "", errBuildUnknown |
| + if h.Unknown || h.Target == "" { |
| + return "", nil, errBuildUnknown |
| + } |
| + if h.Failed { |
| + if a.Planner != nil { |
| + return "", nil, nil // zdd-5's decideWake: recovery onto the good build, or a refusal |
| + } |
| + return "", nil, fmt.Errorf("%w: %s on %s", errTargetFailed, h.Target, host) |
| } |
| installed, _ := a.liveVer(host) |
| - if installed == target { |
| - return target, nil |
| + if installed == h.Target { |
| + return h.Target, nil, nil |
| + } |
| + if direct && a.Planner == nil && h.Staged != nil && h.Staged.Version == h.Target { |
| + return h.Target, h.Staged, nil // run the retained copy; Task 5 records it first |
| } |
| if a.RequestStage != nil { |
| - a.RequestStage(host, target) |
| + a.RequestStage(host, h.Target) |
| } |
| tick := time.NewTicker(250 * time.Millisecond) |
| defer tick.Stop() |
| for { |
| - if v, _ := a.liveVer(host); v == target { |
| - return target, nil |
| + if v, _ := a.liveVer(host); v == h.Target { |
| + return h.Target, nil, nil |
| } |
| select { |
| case <-ctx.Done(): |
| - return "", fmt.Errorf("%w: %s is on %s, wants %s", errStaleLink, host, installed, target) |
| + return "", nil, fmt.Errorf("%w: %s is on %s, wants %s", errStaleLink, host, installed, h.Target) |
| case <-tick.C: |
| } |
| } |
| @@ -2378,41 +2604,45 @@ func heldIdentityOK(target string, id release.StagedIdentity, plan WakePlan, rt |
| (add `registry` to the imports). `a.liveVer` is the `LiveVersion` reader `New` already wires (`activator.go` ~L707); the fakes set it through `fakeBackend.LiveVersion`. Add `HeldTarget` and `RequestStage` to `Service` with doc comments. |
| |
| Call sites in `doWake` (Astra I2 #4): |
| - 1. After the layout refusal (step 0.5) and **before** zdd-5's planning block, when `e.pin == nil` (a hold swap's pinned wake was chosen by the swap from root's install): `held, err := a.wakeCheck(ctx, host); if err != nil { return w, fmt.Errorf("%s: %w", host, err) }`. No lease is held yet. When `held != "" && a.Routes != nil`, re-read the row exactly as step 0 does (`fresh, err := a.Routes.Get(host)`, `rt = *fresh`, `e.route = rt` under `a.mu`) so the planner sees the build root just installed. |
| - 2. In Task 5's step 2b, after `wakeIdentity` succeeds: `if !heldIdentityOK(held, id, e.plan, rt) { idErr = fmt.Errorf("%w: %s would run %s, wants %s", errStaleLink, host, id.Version, held) }`, before `beforeExec`, so the refusal releases the lease the way 2b's other errors do and records nothing. |
| + 1. After the layout refusal (step 0.5) and **before** zdd-5's planning block, when `e.pin == nil` (a hold swap's pinned wake was chosen by the swap from root's install): `held, heldID, err := a.wakeCheck(ctx, host, true); if err != nil { return w, fmt.Errorf("%s: %w", host, err) }`. No lease is held yet. When `held != "" && heldID == nil && a.Routes != nil`, re-read the row exactly as step 0 does (`fresh, err := a.Routes.Get(host)`, `rt = *fresh`, `e.route = rt` under `a.mu`) so the planner sees the build root just installed. |
| + 2. Task 5's `wakeIdentity` gains a `heldID *release.StagedIdentity` parameter, checked after the planned and pinned cases and before `LiveIdentity`: `case heldID != nil: return *heldID, nil`. Step 2b passes it. `StartExe` and `SeedExe` run `release.VerifyStaged` on it before any exec, as for every identity. |
| + 3. In Task 5's step 2b, after `wakeIdentity` succeeds: `if !heldIdentityOK(held, id, e.plan, rt) { idErr = fmt.Errorf("%w: %s would run %s, wants %s", errStaleLink, host, id.Version, held) }`, before `beforeExec`, so the refusal releases the lease the way 2b's other errors do and records nothing. |
| |
| -In `doClaimWake`, the same: the check before zdd-5's claimed planning block, the route re-read, and in Task 5's Step 5b `beforeStart`, before `a.beforeExec`: `if held != "" && s.Version != held && !(e.plan.planned() && !e.plan.FirstWake && s.Version == rt.GoodVersion) { return fmt.Errorf("%w: %s would run %s, wants %s", errStaleLink, e.host, s.Version, held) }`. |
| +In `doClaimWake`, the same with `direct` false (`held, _, err := a.wakeCheck(ctx, rt.Host, false)`: its launcher execs the claim inputs' file, so a retained copy cannot be injected and the wake waits for root): the check before zdd-5's claimed planning block, the route re-read, and in Task 5's Step 5b `beforeStart`, before `a.beforeExec`: `if held != "" && s.Version != held && !(e.plan.planned() && !e.plan.FirstWake && s.Version == rt.GoodVersion) { return fmt.Errorf("%w: %s would run %s, wants %s", errStaleLink, e.host, s.Version, held) }`. |
| |
| - [ ] **Step 4: The agent's answer.** Add to `internal/host/buildstate.go`: |
| |
| ```go |
| // heldTarget is Service.HeldTarget: the target a held instance must wake |
| -// onto. held is false for an instance root follows the channel for; unknown |
| -// is rule 3's "build unknown". |
| -func (b *buildStates) heldTarget(host string) (string, bool, bool) { |
| +// onto, in root's rule order. Held is false for an instance root follows the |
| +// channel for. Failed and Staged feed the wake's own admission rule, which |
| +// holds with no planner (Astra I3 #2, #5). |
| +func (b *buildStates) heldTarget(host string) activator.HeldWake { |
| rt, err := b.reg.Get(host) |
| if err != nil || rt == nil { |
| - return "", false, false |
| + return activator.HeldWake{} |
| } |
| u, err := release.ParseUpdateSettings(rt.UpdateSettings) |
| if err != nil || u.Default() { |
| - return "", false, false |
| + return activator.HeldWake{} |
| } |
| bs, _ := release.ParseBuildStateCopy(rt.BuildState) |
| c, _ := release.ReadConsumed(b.optDir, host) |
| - // Root's order (Target, §4.3): a pin outranks a pending request (Astra |
| - // I2 #7). When root's own decision found the target failed, root will |
| - // not install it: the wake is not held, and the failure-aware planner |
| - // decides what runs (Astra I2 #4). |
| + refusal, _ := release.ReadRefusal(b.bakeDir, host) |
| + // rootFailed: root's own decision found v failed, or root's refusal |
| + // record (a build that did not come up here) names it. |
| rootFailed := func(v string) bool { |
| - return c != nil && c.Reason == string(release.ReasonFailed) && c.Detail == v |
| + return (c != nil && c.Reason == string(release.ReasonFailed) && c.Detail == v) || |
| + (refusal != nil && refusal.Version == v) |
| } |
| - held := func(v string) (string, bool, bool) { |
| - if rootFailed(v) { |
| - return "", false, false |
| + held := func(v string) activator.HeldWake { |
| + h := activator.HeldWake{Target: v, Held: true, Failed: rootFailed(v)} |
| + if !h.Failed { |
| + h.Staged = b.retained(*rt, v) |
| } |
| - return v, true, false |
| + return h |
| } |
| + // Root's order (Target, §4.3): a pin outranks a pending request (I2 #7). |
| if u.Pin != "" { |
| return held(u.Pin) |
| } |
| @@ -2426,19 +2656,36 @@ func (b *buildStates) heldTarget(host string) (string, bool, bool) { |
| } |
| heldByRoot := c != nil && (c.Reason == string(release.ReasonManual) || c.Reason == string(release.ReasonMajor) || c.Reason == string(release.ReasonUnknown)) |
| if u.Updates != release.UpdatesManual && !heldByRoot { |
| - return "", false, false |
| + return activator.HeldWake{} |
| } |
| if c != nil && c.Reason == string(release.ReasonUnknown) { |
| - return "", true, true |
| + return activator.HeldWake{Held: true, Unknown: true} |
| } |
| if bs.State != nil && bs.State.LastExecuted != "" { |
| return held(bs.State.LastExecuted) |
| } |
| - return "", false, false // no build state: whatever root installed is the instance's |
| + return activator.HeldWake{} // no build state: whatever root installed is the instance's |
| +} |
| + |
| +// retained is v's staged executable on this box when its bytes match its |
| +// staged manifest's sha256 for this platform (the check stageAuthorized's |
| +// recovery uses), else nil (Astra I3 #5). |
| +func (b *buildStates) retained(rt registry.Route, v string) *release.StagedIdentity { |
| + acct := release.RouteAccount(rt) |
| + m, err := release.ReadStagedManifest(b.optDir, acct, v, rt.App) |
| + if err != nil || m == nil { |
| + return nil |
| + } |
| + path := release.VersionPath(b.optDir, acct, v, rt.App) |
| + sum, err := release.FileSHA256(path) |
| + if err != nil || sum != m.Artifacts[runtime.GOOS+"-"+runtime.GOARCH].SHA256 { |
| + return nil |
| + } |
| + return &release.StagedIdentity{Version: v, StagedPath: path, Digest: sum} |
| } |
| ``` |
| |
| -Root's consumed reason decides whether a `major` hold is in force (the agent does not read tags). In `runAgent`, set `svc.HeldTarget = bs.heldTarget` and `svc.RequestStage = func(host, build string) { stageRequester(dataDir())(host, build, nil) }` when `bs != nil`. Before every wake the copy is refreshed: add `_ = bs.refresh(ctx, host)` at the top of the `HeldTarget` call path by wrapping: `svc.HeldTarget = func(h string) (string, bool, bool) { _ = bs.refresh(context.Background(), h); return bs.heldTarget(h) }` (§4.2: "refreshed at every reconcile and before every wake"). |
| +`buildStates` gains `bakeDir string`, set in `newBuildStates` to `dataDir()` (the `BakeDir` every agent pass uses, `agent.go:995`); add `runtime` and `internal/activator` to the imports. Root's consumed reason decides whether a `major` hold is in force (the agent does not read tags). In `runAgent`, set `svc.RequestStage = func(host, build string) { stageRequester(dataDir())(host, build, nil) }` when `bs != nil`, and refresh the copy before every wake (§4.2: "refreshed at every reconcile and before every wake"): `svc.HeldTarget = func(h string) activator.HeldWake { _ = bs.refresh(context.Background(), h); return bs.heldTarget(h) }`. |
| |
| Add to `internal/host/buildstate_test.go`: |
| |
| @@ -2458,8 +2705,8 @@ func TestAMajorHoldStaysHeldWhileARequestBakes(t *testing.T) { |
| if err := release.WriteConsumed(b.optDir, release.Consumed{Host: "a.example.com", Reason: string(release.ReasonMajor), Desired: "v2", Baking: "v3"}); err != nil { |
| t.Fatal(err) |
| } |
| - if target, held, _ := b.heldTarget("a.example.com"); !held || target != "v2" { |
| - t.Fatalf("heldTarget = %q, %v; want v2, held", target, held) |
| + if h := b.heldTarget("a.example.com"); !h.Held || h.Target != "v2" { |
| + t.Fatalf("heldTarget = %+v; want v2, held", h) |
| } |
| } |
| ``` |
| @@ -2480,14 +2727,14 @@ func TestAPinOutranksAPendingRequestAtTheWake(t *testing.T) { |
| if err := release.WriteConsumed(b.optDir, release.Consumed{Host: "a.example.com", Completed: 1, Resolved: &release.ResolvedRequest{ID: 2, Target: "v4"}}); err != nil { |
| t.Fatal(err) |
| } |
| - if target, held, _ := b.heldTarget("a.example.com"); !held || target != "v5" { |
| - t.Fatalf("heldTarget = %q, %v; want the pin v5", target, held) |
| + if h := b.heldTarget("a.example.com"); !h.Held || h.Target != "v5" { |
| + t.Fatalf("heldTarget = %+v; want the pin v5", h) |
| } |
| } |
| |
| -// Astra I2 #4: root found the pin failed and will not install it; the wake |
| -// does not wait for it, and the failure-aware planner decides. |
| -func TestARootFailedTargetIsNotHeld(t *testing.T) { |
| +// Astra I3 #2: root's failed decision, or its refusal record, marks the |
| +// target failed, so the wake's own rule refuses it with no planner. |
| +func TestARootFailedTargetIsReportedFailed(t *testing.T) { |
| b, _, reg := testBuildStates(t) |
| if err := reg.SetUpdateSettings("a.example.com", release.UpdateSettings{Updates: release.UpdatesAuto, Channel: "stable", Pin: "v5"}.Column()); err != nil { |
| t.Fatal(err) |
| @@ -2495,13 +2742,45 @@ func TestARootFailedTargetIsNotHeld(t *testing.T) { |
| if err := release.WriteConsumed(b.optDir, release.Consumed{Host: "a.example.com", Reason: string(release.ReasonFailed), Detail: "v5"}); err != nil { |
| t.Fatal(err) |
| } |
| - if _, held, _ := b.heldTarget("a.example.com"); held { |
| - t.Fatal("a pin root refused as failed held the wake for an install that never comes") |
| + if h := b.heldTarget("a.example.com"); !h.Held || !h.Failed || h.Target != "v5" || h.Staged != nil { |
| + t.Fatalf("heldTarget = %+v; want v5 held and failed, no retained copy offered", h) |
| + } |
| + if err := release.WriteConsumed(b.optDir, release.Consumed{Host: "a.example.com", Reason: string(release.ReasonPin), Detail: "v5"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := release.WriteRefusal(b.bakeDir, release.Refusal{Host: "a.example.com", App: "km", Version: "v5", Reason: "not up"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if h := b.heldTarget("a.example.com"); !h.Failed { |
| + t.Fatalf("a refusal record for the pin = %+v; want failed", h) |
| + } |
| +} |
| + |
| +// Astra I3 #5: a retained copy whose bytes match its staged manifest is |
| +// offered; one whose bytes differ is not. |
| +func TestARetainedCopyIsOfferedOnlyWhenVerified(t *testing.T) { |
| + b, _, reg := testBuildStates(t) |
| + if err := reg.SetUpdateSettings("a.example.com", release.UpdateSettings{Updates: release.UpdatesManual, Channel: "stable"}.Column()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetBuildState("a.example.com", release.BuildStateCopy{Known: true, Replica: true, State: &release.BuildState{LastExecuted: "v2"}}.Column()); err != nil { |
| + t.Fatal(err) |
| + } |
| + stageWithManifest(t, b.optDir, "bab", "km", "v2", []byte("two")) // the artifact and its manifest sibling, sha256 matching |
| + h := b.heldTarget("a.example.com") |
| + if h.Staged == nil || h.Staged.Version != "v2" || h.Staged.Digest != fileSHA(t, h.Staged.StagedPath) { |
| + t.Fatalf("heldTarget = %+v; want the verified retained v2", h) |
| + } |
| + if err := os.WriteFile(release.VersionPath(b.optDir, "bab", "v2", "km"), []byte("tampered"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if h := b.heldTarget("a.example.com"); h.Staged != nil { |
| + t.Fatal("a staged copy whose bytes differ from its manifest was offered") |
| } |
| } |
| ``` |
| |
| -The first fails against the I1 order (the resolved request's v4 came first); the second fails without `rootFailed` (the pin returned held). |
| +The pin test fails against the I1 order (the resolved request's v4 came first). `stageWithManifest` writes the artifact with `release.StageArtifact` and its manifest sibling with the artifact's sha256 under `runtime.GOOS+"-"+runtime.GOARCH` (copy the sibling writer `ensureStaged` uses); `fileSHA(t, path)` is a test helper in `buildstate_test.go` that returns `release.FileSHA256(path)` and fails the test on an error. `testBuildStates` sets `bakeDir` to a temp dir. Add `os` and `runtime` to the test imports. |
| |
| - [ ] **Step 5: Run the packages.** Run: `cd $W && go build ./... && TMPDIR=/tmp/k go test -race ./internal/activator/ ./internal/host/ -count=1`. Expected: PASS. |
| |
| @@ -3109,9 +3388,18 @@ func TestEveryBoxOfThisBinaryReportsTheProtocol(t *testing.T) { |
| if m := boxSettingsMetrics(); m != status.UpdateSettingsProtocol { |
| t.Fatalf("this binary reports %d", m) |
| } |
| + // Astra I3 #1: a box whose build-state service failed does not claim to |
| + // enforce settings, so the console keeps refusing them. |
| + buildStatesFailed.Store(true) |
| + t.Cleanup(func() { buildStatesFailed.Store(false) }) |
| + if m := boxSettingsMetrics(); m != 0 { |
| + t.Fatalf("a box with no build-state service reports %d", m) |
| + } |
| } |
| ``` |
| |
| +In `runAgent`, right after `bs, bsErr := newBuildStates(…)`: `if bsErr != nil { buildStatesFailed.Store(true) }`. Add `sync/atomic` to `updatesreport.go`'s imports. |
| + |
| - [ ] **Step 2: Run, see it fail, then implement.** In `snapshot.go`, add `HostUpdates` (the index's shape plus `Detail`, `Baking string` and `Major *int`, the instance's effective major hold, so a list can name an inherited hold: Astra I1 #8, #12) and `Updates map[string]HostUpdates \`json:"updates,omitempty"\`` on `BoxMetrics`, with a doc comment saying it is hostname-keyed and private like `RouteVersions`. Create `internal/host/updatesreport.go`: |
| |
| ```go |
| @@ -3126,8 +3414,19 @@ import ( |
| |
| // boxSettingsMetrics is this binary's settings protocol: it applies pin, |
| // manual and major, and its retention protects the builds they name |
| -// (settings spec §4.4). It lifts the console's refusal. |
| -func boxSettingsMetrics() int { return status.UpdateSettingsProtocol } |
| +// (settings spec §4.4). It lifts the console's refusal. A box whose |
| +// build-state service failed to start (buildStatesFailed, set by runAgent |
| +// from bsErr) reports 0: it refuses every exec and enforces nothing |
| +// (Astra I3 #1). |
| +func boxSettingsMetrics() int { |
| + if buildStatesFailed.Load() { |
| + return 0 |
| + } |
| + return status.UpdateSettingsProtocol |
| +} |
| + |
| +// buildStatesFailed is set once by runAgent when newBuildStates errs. |
| +var buildStatesFailed atomic.Bool |
| |
| // updatesReport is root's last decision per host with settings, for the |
| // console's list and instance page. |
| @@ -3221,6 +3520,7 @@ Invariants, checked after every step: |
| 3. **No install lacks evidence**: every repoint is the head (pointer) or a build with a promotion record in the model, or the box's own staged copy. |
| 4. **Failure records outrank**: no box repoints onto a build it marked failed for an instance that has not served it. |
| 5. **Unresolved requests block nothing**: a held instance with an unresolved request whose head is baking still recovers onto `last_executed` on a fresh box. |
| +6. **A request resolves at its first eligible pass, pin or not** (Astra I3 #6): the world records `firstEligible[R]`, the head at the first root pass where R is pending and that head is not baking on the box; every resolution of R any box records names exactly that build. |
| |
| - [ ] **Step 1: Write the simulation** with these pieces, each a small function in the test file: `type simBox struct{ consumed *Consumed; live string; failed map[string]bool; staged map[string]bool }`, `type simWorld struct{ boxes []*simBox; at int; state BuildState; settings UpdateSettings; head string; baking bool; promoted map[string]bool; lastRan string; rng *rand.Rand }`, `func (w *simWorld) rootPass(b *simBox)`, `func (w *simWorld) wake(b *simBox)`, `func (w *simWorld) check(t *testing.T, step int, action string)`, and the `for seed := …` loop. `rootPass` builds a `TargetInput` from the box and world exactly as `settingsTarget` does (copy its body's input construction) and applies the decision; `wake` applies the merge of `buildStates.beforeExec` (copy its body against the in-memory state). A failed invariant prints the seed, the step and the action trace. |
| |
| @@ -3248,15 +3548,16 @@ For each mutation below: apply it with the Edit tool (or `sed -i` inside the cop |
| | M3 | the wake merge completes R at install (in `rootPass`) instead of at the wake | 1 or 2 | |
| | M4 | `Target` drops the `failed(u.Pin)` check | 4 | |
| | M5 | `Target` rule 3 returns the head when `!in.Build.Known` | 5 (and Review Focus 4's unit test) | |
| +| M6 | `Target` returns the pin before the request bookkeeping (the I2 order) | 6 (and Task 2's pin cases) | |
| |
| -Record each result in the commit message. Delete the copy: `rm -rf $TMPDIR/settings-4/mut`. |
| +Add `firstEligible map[uint64]string` to `simWorld`. Record each result in the commit message. Delete the copy: `rm -rf $TMPDIR/settings-4/mut`. |
| |
| - [ ] **Step 4: Commit.** |
| |
| ```bash |
| cd $W && gofmt -l internal/release && go vet ./internal/release/ |
| git add internal/release/updatesim_test.go |
| -git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit -m "settings-4 task 11: request lifecycle simulation; 3000 seeds clean, M1-M5 each caught |
| +git -c user.name=claude -c user.email='claude~paul@keymail.dev' commit -m "settings-4 task 11: request lifecycle simulation; 3000 seeds clean, M1-M6 each caught |
| |
| Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| ``` |