carlos / platform Public

docs: region HA plan 1b-next rev 9 keeps a live source's execution and a stop's recorded revision

Astra round 8, two P2s, both accepted. FenceExhaustedTarget now leaves
the live source as op.exec: in starting or releasing it consumes the
target's fence and records stuck, with the source's phase and release
records unchanged. It moves to a stuck pinning with exec "none" only
when the dead target was op.exec. Ruling 23 is amended to match.
Release now keeps the recorded directory revision on every retry where
positions are recorded, not only on recovery. Dry-run on 3407d114; 2
guards removed in turn, each failing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 470d7ec602e62d039ab7d6e7aadb46b92eaae3e7 parent a78df50
1 file changed, +86 −8
  • docs/superpowers/plans/2026-10-07-region-ha-1b-next-relocation.md +86 −8
diff --git a/docs/superpowers/plans/2026-10-07-region-ha-1b-next-relocation.md b/docs/superpowers/plans/2026-10-07-region-ha-1b-next-relocation.md
index 626e6ff85..43837ad25 100644
--- a/docs/superpowers/plans/2026-10-07-region-ha-1b-next-relocation.md
+++ b/docs/superpowers/plans/2026-10-07-region-ha-1b-next-relocation.md
@@ -1,15 +1,26 @@
-# Region HA 1b-next: Relocation, Row Cases, Create Choice and Fences Implementation Plan (rev 8)
+# Region HA 1b-next: Relocation, Row Cases, Create Choice and Fences Implementation Plan (rev 9)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build what region HA spec 1 had to leave until the host lifecycle claim was on main: the `relocating` operation within one region, released by the source's stop evidence (drain) or by its termination fence (failover); reconcile's row cases for it; create's once-only choice of executor box with `boxchoice.Choose`; and every claim CAS that consumes a termination fence. All of it dormant behind `CARLOS_REGION_HA`, and none of it mergeable until the joint two-box simulation round passes (L18).
-**Architecture:** Two stages (ruling 24). **Stage A, build now:** Tasks 1 to 7 and 9a, buildable on main today and dry-run on every revision: the claim library's relocation transitions and `released` coverage (`internal/hostclaim`), create's executor selection (`internal/hostclaim`, `internal/hostops`), the row and wake decisions (`internal/hostbox`), the writers-stopped observation (`internal/stopevidence`), the regional worker and the participant driver (new `internal/hostrelocate`), and the box-side release adapter with its receipt verifier (`internal/host`). **Stage B, gated:** Tasks 8a, 8b, 8c, 9 and 10 bind to claim Plan 6b and moves Plans 1 and 2, none merged. They keep their design and contracts here; their code is rewritten against the real 6b code after it merges and re-reviewed then (the "Stage B re-plan" step before Task 8a lists every obligation rounds 1 to 7 placed on them). What those need and no plan declares is a numbered request, never an assumption. The last task is the merge gate. The worker only reads termination fences: `hostfence.TerminationWorker` writes them, and `hostactors.Directory` is the only roster reader.
+**Architecture:** Two stages (ruling 24). **Stage A, build now:** Tasks 1 to 7 and 9a, buildable on main today and dry-run on every revision: the claim library's relocation transitions and `released` coverage (`internal/hostclaim`), create's executor selection (`internal/hostclaim`, `internal/hostops`), the row and wake decisions (`internal/hostbox`), the writers-stopped observation (`internal/stopevidence`), the regional worker and the participant driver (new `internal/hostrelocate`), and the box-side release adapter with its receipt verifier (`internal/host`). **Stage B, gated:** Tasks 8a, 8b, 8c, 9 and 10 bind to claim Plan 6b and moves Plans 1 and 2, none merged. They keep their design and contracts here; their code is rewritten against the real 6b code after it merges and re-reviewed then (the "Stage B re-plan" step before Task 8a lists every obligation rounds 1 to 8 placed on them). What those need and no plan declares is a numbered request, never an assumption. The last task is the merge gate. The worker only reads termination fences: `hostfence.TerminationWorker` writes them, and `hostactors.Directory` is the only roster reader.
**Tech Stack:** Go 1.26, module `github.com/carlosframework/platform`; `release.Store` (create-only and ETag compare-and-swap puts); the claim library (`hostclaim`), roster (`hostactors`), fences (`hostfence`, `termfence`), box choice (`boxchoice`), files (`hostfiles`), generation selection (`genselect`), stop evidence (`stopevidence`, `internal/host` stop receipts), execution controller (`hostexec`).
**Spec:** `docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md`, rev 5.4 with §24 (rulings H1 to H19 final). Read §0, §2, §5.2, §5.3, §5.8, §5.9, §5.10, §9 (L1 to L20, accepted) and §24 first. Then the claim spec rev 6.30 on `origin/design-replica-ownership` (`docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md`): §1.1's activation gate, §4 ("Same-region relocation (L1)" and the relocation exception to CC11), §5.1 and P5-D2/P5-D6 (the create intent), §6.1 ("Release before full stop (L10)"), §6.4a item 2 (`released`), §7.3 D2 ("Automatic termination (L2, L4)", "The fence's journal reference") and D4 ("Automatic loss persistence"). Then moves rev 7.22 on `origin/design-moves-any-region` §7.6 ("One install routine, two sources", L16 and L17) and §7.10 (the pre-flip conversion). The contracts note `docs/superpowers/notes/2026-10-07-region-ha-contracts.md` gives `hostfiles` v2, `CopyManifest`, `Publish` and `boxchoice`. Plan 1b (`docs/superpowers/plans/2026-10-06-region-ha-1b-claim-readers.md`) lists in "Waits for the claim" what this plan now builds.
+## Rev 9: round 8 dispositions
+
+Astra's round 8 (`CODEX-PLAN-REGION-HA-1BN-R8.md`) resolved the release-retry finding and added two P2s. I checked both against `internal/host/claimstop.go` and `internal/hostguard/fence.go` and accepted both; none is declined. Both fixes are stage A code. They were dry-run on a clean 3407d114: build, vet for darwin and windows, cross-builds, and the targeted `hostclaim`, `hostrelocate` and `host` tests. Two new guards were each removed in turn and their tests failed (listed in the Self-review). Ruling 23 is amended.
+
+| # | Stage | Evidence checked | Disposition | What changed | Where |
+|---|---|---|---|---|---|
+| 1 (P2) | A | Rev 8's `FenceExhaustedTarget` always set `op.exec` to "none" and the phase to `pinning`. In `starting` and `releasing`, `op.exec` is the live source, and the verified fence is the target's. Root's stop refuses a requester that is not `op.exec` (claimstop.go:184, `observed.Claim.Op.Exec != req.Box`). | accepted | The fix depends on who holds `op.exec`. If another box holds it (the live source in `starting` or `releasing`), the CAS consumes the target's fence, retains its loss, puts it on `lost`, drops it from the participants and records `stuck`. It leaves `op.exec`, the phase, the stamp and every release record unchanged. The old move to a stuck `pinning` with `op.exec` "none" now happens only when `op.exec` is the dead target itself (its own fence revokes it) or is already "none". | Task 1 (`TestRelocationExhaustedTargetIsFencedIntoAStuckPinning/dead_while_the_source_executes_in_starting` and `…_in_releasing`, the latter with the source's release journaled first); ruling 23 |
+| 2 (P2) | A | Rev 8's `Release` restored `recorded.ExpectedDirectoryRevision` only when the boot or controller had changed. Root's stop compares that revision with its stop inventory (claimstop.go:191). The fence target also carries it (`hostguard.FenceTarget.DirectoryRevision`, fence.go). | accepted | When matching positions are recorded, `Release` always asks under the recorded `ExpectedDirectoryRevision`, recovery or not. Only the recovery branch takes the recorded boot and controller. | Task 7 (`TestRelocationReleaseReplaysOnlyRecordedPositions/root_fails_after_the_producers_closed`: the retry runs under the same boot and controller, with the roster at revision 5. Root is asked at revision 4, and only the producers' drain and the receipt run.) |
+
+Astra's readings of rulings 1 to 22 and 24 stand. Ruling 23 is amended as finding 1 requires.
+
## Rev 8: round 7 dispositions
Astra's round 7 (`CODEX-PLAN-REGION-HA-1BN-R7.md`) resolved the kcmp finding, confirmed that stage B's contracts are complete, and added one P2. We checked it against `internal/host/claimstop.go` and accepted it; none is declined. The fix is stage A code. It was dry-run again on a clean 3407d114: build, vet for darwin and windows, cross-builds, and the targeted `internal/host` tests. Two new guards were removed in turn and each made the new test fail (listed in the Self-review).
@@ -167,7 +178,7 @@ Each was a conflict between the authorities or a gap in them. Each says what it
20. **A relocation's execution controller is finished when its participation ends** (rev 3). `hostexec` keeps the host's lifetime lock until `Close`, so the participant sweeps its retained controllers once per pass and closes every one whose operation the claim no longer runs with this box as a participant. The relocation controller's `Cleanup` hook is scoped: it retires only the relocation's own partial installation (an unopened P's local install) and does nothing once this box has admitted a writer under the operation or serves the host, so closing it after a successful relocation stops nothing. *Rev 6:* a durable record is cleared only straight after that scoped cleanup succeeds in the same call (`Finish` repeats it after `Close`, and on a restart), because hostexec reports a cleanup failure from `Close` once and nil afterwards; the participation is journaled before its controller opens, so a restart finds every record. The serving writer belongs to the wake assembly's own controller from its admission on. A completed operation's `hold_op` on the serving executor's row is cleared by reconcile under the activation's verified ownership; a running operation's hold is never touched. *Cost if wrong:* a scope rule in one hook; the lock release is hostexec's.
21. **The pinned files manifest is retained by the pinner** (rev 3; B2-4). Before the pin object exists, the exact manifest bytes are written create-only to `control/relocations/<host>/<op>/manifest-<sha256>.json`, and P's manifest is written from that copy, never from the source prefix again: a late revision of the source changes nothing an admitted pin reads. *Cost if wrong:* one small object per pin under the prefix ruling 5's storage follow-up covers.
22. **The writers-stopped observation is root's** (rev 3; L10 "a complete privileged descriptor scan"). The agent, uid carlos with `CAP_NET_BIND_SERVICE` alone, can read only its own descriptor table; it records the replicator's descriptors from `/proc/self` at open and asks root, over the authenticated instance-control socket, to make the complete scan with its cgroup manager (R3). An agent-side scan fails closed on the first unreadable table. *Cost if wrong:* one more root endpoint, beside the stop collector's.
-23. **An exhausted target that dies is fenced into a stuck pinning** (rev 4; §5.8 "The bound": "never between `installed` and a probe" is a live target's safe point). `FenceExhaustedTarget` consumes the fence in one CAS, retains the loss, revokes the dead installation's authority and leaves the operation stuck at `pinning` with its pin, and P's remote build when nothing opened it, for the operator's replacement; nothing chooses a third target. *Cost if wrong:* the stuck state's shape; the operator path is the claim's.
+23. **An exhausted target that dies is fenced into a stuck pinning** (rev 4; §5.8 "The bound": "never between `installed` and a probe" is a live target's safe point). *Rev 9:* that holds only when the dead target is `op.exec`, or `op.exec` is already "none". While the live source is `op.exec` (`starting`, `releasing`), the CAS consumes the target's fence and records `stuck`. It keeps the source's execution identity, phase and release evidence, because a target's fence is no evidence against the source (claim §4), and root's stop needs the source to remain `op.exec`. Otherwise `FenceExhaustedTarget` consumes the fence in one CAS, retains the loss, revokes the dead installation's authority and leaves the operation stuck at `pinning` with its pin, and P's remote build when nothing opened it, for the operator's replacement; nothing chooses a third target. *Cost if wrong:* the stuck state's shape; the operator path is the claim's.
24. **Two stages** (controller ruling, rev 5). Rounds 3 and 4 found most of their defects in the gated tasks' integration with 6b, itself unmerged and changing; polishing code against a moving plan does not converge. Stage A (Tasks 1 to 7, 9a) is build-ready: compiled, dry-run, every finding fixed in code. Stage B (8a, 8b, 8c, 9, 10) keeps its design and contracts; its code is rewritten against the real 6b code after it merges and re-reviewed then. Stage B findings are answered at the contract level: what must happen, which request covers it, which test proves it. The "Stage B re-plan" step lists every stage B obligation from rounds 1 to 4, so none is lost. *Cost if wrong:* a second review round on stage B after 6b lands; the alternative is tuning against text that will not survive.
18. **D4's automatic loss is retained as `evidence_refs`** (rev 2; claim §7.3 D3 "enrollment-qualified fence references in `evidence_refs` and stable loss identities in `loss_refs`"). `hostclaim.AutomaticLoss` holds the fence reference, its observation time, the box, enrollment and role, the box's last writer session, the discharge verdict, timer knowledge `unknown` and whether the console has reported it; the id is deterministic over (operation, box, fence) so a replayed transition dedupes. It lives beside `loss_refs`, outside `op`, and so survives the serving CAS. The console's materialization is after this plan (R5). *Cost if wrong:* a field name; the retention is what D4 requires.
@@ -197,7 +208,7 @@ Left out, with where it goes:
- **Go environment.** `GOFLAGS=-buildvcs=false`. Leave `GOCACHE` at its default; if the sandbox refuses it, `GOCACHE=$TMPDIR/gocache` for that command. Socket tests need `TMPDIR=/tmp/k` (`mkdir -p /tmp/k`). Run tests niced (`nice -n 19`), targeted with `-run`; never the full `-race` suite, and no simulation sweeps. Scratch goes under `$TMPDIR`.
- **Gate before each commit:** `go build ./... && go vet ./<pkgs>/ && gofmt -l <dirs>` (empty), the task's targeted tests, then `GOOS=darwin go build ./... && GOOS=windows go build ./...`. (`GOOS=windows go vet ./internal/...` fails on main's own test files, `release/versions_test.go`, `appconfig/syncexec_test.go` and `activator/activator_test.go`; vet the task's packages instead.)
- Commit messages: lowercase `area:` prefix and a sentence stating the behaviour, then a blank line and `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. Work on a branch cut from `origin/main` (suggested `region-ha-1b-next`). Merging is Paul's call, through `amadan branch merge`, and only after Task 11.
-- **Dry run.** Tasks 1 to 7 were applied in order to `origin/main` at 3407d114, built, vetted, cross-built for darwin, windows and linux/arm64, and their tests run; nineteen guards were each removed in turn and their test watched to fail (rev 1), then rev 2's twenty-five, rev 3's eleven, rev 4's eight, rev 5's seven, rev 6's eleven, rev 7's three and rev 8's two (listed in the Self-review). Then the code was removed; only this document is committed.
+- **Dry run.** Tasks 1 to 7 were applied in order to `origin/main` at 3407d114, built, vetted, cross-built for darwin, windows and linux/arm64, and their tests run; nineteen guards were each removed in turn and their test watched to fail (rev 1), then rev 2's twenty-five, rev 3's eleven, rev 4's eight, rev 5's seven, rev 6's eleven, rev 7's three, rev 8's two and rev 9's two (listed in the Self-review). Then the code was removed; only this document is committed.
## Review Focus
@@ -712,6 +723,44 @@ func TestRelocationExhaustedTargetIsFencedIntoAStuckPinning(t *testing.T) {
t.Fatalf("fenced as exhausted with replacements left: %v", err)
}
})
+ // Round 8 finding 1: while the live source is still op.exec, the
+ // target's fence consumes the target and records stuck; the source's
+ // execution identity, phase and release evidence stay.
+ for _, phase := range []string{PhaseStarting, PhaseReleasing} {
+ t.Run("dead while the source executes in "+phase, func(t *testing.T) {
+ s, o := relAtPhase(t, phase)
+ if phase == PhaseReleasing {
+ h := o.Claim.Holder
+ o = step(t, s, o, JournalRelease("rel-1", Release{ReleasedBy: relSource, Enrollment: relEnrollment(relSource), Generation: h.Generation, Session: h.Session, FinalTXID: 40, ManifestSeq: 7, Receipt: "r", ReceiptSHA256: strings.Repeat("d", 64)}))
+ }
+ o = exhaust(t, s, o)
+ before := o.Claim
+ ref := putFence(t, s, relSpare2)
+ o, err := FenceExhaustedTarget(ctx, s, o, "rel-1", ref, nil, relAuthority{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := o.Claim
+ if c.Op.Phase != phase || c.Op.Exec != relSource || c.Holder.Stamp != before.Holder.Stamp || c.Op.Journal["stuck"] == "" ||
+ !slices.Contains(c.Op.Participants, relSource) || slices.Contains(c.Op.Participants, relSpare2) || !slices.Contains(c.Lost, relSpare2) {
+ t.Fatalf("the live source was revoked: %+v lost %v", c.Op, c.Lost)
+ }
+ for _, k := range []string{"release", "release.phase", "releasing_since", "exec_ack." + relSource} {
+ if c.Op.Journal[k] != before.Op.Journal[k] {
+ t.Fatalf("%s changed: %q -> %q", k, before.Op.Journal[k], c.Op.Journal[k])
+ }
+ }
+ if phase == PhaseReleasing && c.Op.Journal["release"] == "" {
+ t.Fatal("the source's release evidence was dropped")
+ }
+ if l := c.EvidenceRefs[len(c.EvidenceRefs)-1]; l.Box != relSpare2 || l.Fence != ref {
+ t.Fatalf("the dead target's loss: %+v", c.EvidenceRefs)
+ }
+ if err := c.Validate(); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
t.Run("dead after its local install, P unopened", func(t *testing.T) {
s, o := relAtPhase(t, PhasePinning)
o = exhaust(t, s, o)
@@ -2249,7 +2298,10 @@ func ReplaceTarget(ctx context.Context, s release.Store, observed Observed, opID
// FenceExhaustedTarget is the terminal transition for a target terminated
// after the operation has used its replacements (§5.8 "The bound"; round 3
// finding 5): it consumes the fence (the loss is retained, the box goes on
-// lost), revokes the dead installation's authority (op.exec "none", the
+// lost) and records stuck. While another box is op.exec (the live source
+// in starting or releasing; round 8 finding 1) nothing else changes: the
+// target's fence revokes nothing of the source's. Otherwise (op.exec is
+// the dead target, or none) it revokes the dead installation's authority (op.exec "none", the
// target off the barrier, its receipt dropped), leaves the operation at
// pinning with its pin and, when P was never opened, its remote build kept
// for the operator's replacement, or a nested pinning when P was opened,
@@ -2286,6 +2338,15 @@ func FenceExhaustedTarget(ctx context.Context, s release.Store, observed Observe
return err
}
removeParticipant(c, old)
+ if c.Op.Exec != old && c.Op.Exec != "none" {
+ // The executor is another box (the live source in starting or
+ // releasing): the target's fence is no evidence against it.
+ // Its execution identity, phase and release records stay, so
+ // root still accepts its own stop (claimstop.go requires the
+ // requester to be op.exec); only stuck is new.
+ c.Op.Journal["stuck"] = fmt.Sprintf("%s terminated after %d target replacements", old, MaxTargetReplacements)
+ return nil
+ }
opened := preparedOpened(c)
c.Op.Exec, c.Op.Phase = "none", PhasePinning
keys := []string{"local_install", "probe", "release", "release.phase", "probing_since", "probe_failed_since"}
@@ -6971,7 +7032,7 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
### Task 7: Release evidence on the box (`internal/host`)
-`StopReceiptReleases` is `hostclaim.ReleaseAuthority` over main's stop receipts: the receipt the journal names hashes to the journaled digest, is canonical, sits at its own key, and is the full stop (`kind: stopped`) of exactly that host, incarnation, operation, phase, box, enrollment and writer session, with obligation `relocation-release` (ruling 2). `RelocationEffects` is `hostrelocate.Effects` over providers: `Release` asks root for stop evidence and runs steps 1 to 3 inside the drain root calls back (stop the writers, observe them stopped, database flush, files flush), records the flushed positions durably against the exact stop, and only then lets the producers drain; a failed step stops everything after it; a receipt root replays is paired with the positions this exact stop recorded, or refused, never guessed at. The observation (`Observe`) is a provider because the complete descriptor scan is root's (ruling 22). After a restart, `Release` asks root under the identity the flush was recorded with, offers no drain of its own, and pairs root's durable receipt with the record. *Rev 8:* a retry by the same controller, after root failed between the drain and its durable receipt, resumes at the producers' idempotent drain with the recorded positions and never flushes again. `ReleaseFlushFiles` is step 3 in full: the files directory, the wake state and the env file in one manifest revision. Controllers are opened under the agent's lifetime and retained (`RelocationControllers`); `Ready` checks one before every effect. *Rev 6:* each participation is journaled before its controller opens, and a durable record is cleared only straight after the scoped cleanup succeeds in the same `Finish` (round 5, R4-5); the journal listing ignores uncommitted `.tmp` files and reports a damaged committed entry beside the readable ones (finding 4); a removed env file is flushed as a deletion (finding 5).
+`StopReceiptReleases` is `hostclaim.ReleaseAuthority` over main's stop receipts: the receipt the journal names hashes to the journaled digest, is canonical, sits at its own key, and is the full stop (`kind: stopped`) of exactly that host, incarnation, operation, phase, box, enrollment and writer session, with obligation `relocation-release` (ruling 2). `RelocationEffects` is `hostrelocate.Effects` over providers: `Release` asks root for stop evidence and runs steps 1 to 3 inside the drain root calls back (stop the writers, observe them stopped, database flush, files flush), records the flushed positions durably against the exact stop, and only then lets the producers drain; a failed step stops everything after it; a receipt root replays is paired with the positions this exact stop recorded, or refused, never guessed at. The observation (`Observe`) is a provider because the complete descriptor scan is root's (ruling 22). After a restart, `Release` asks root under the identity the flush was recorded with, offers no drain of its own, and pairs root's durable receipt with the record. *Rev 8:* a retry by the same controller, after root failed between the drain and its durable receipt, resumes at the producers' idempotent drain with the recorded positions and never flushes again. *Rev 9:* whenever positions are recorded for the stop, the request keeps the recorded directory revision, even if the roster has moved on. `ReleaseFlushFiles` is step 3 in full: the files directory, the wake state and the env file in one manifest revision. Controllers are opened under the agent's lifetime and retained (`RelocationControllers`); `Ready` checks one before every effect. *Rev 6:* each participation is journaled before its controller opens, and a durable record is cleared only straight after the scoped cleanup succeeds in the same `Finish` (round 5, R4-5); the journal listing ignores uncommitted `.tmp` files and reports a damaged committed entry beside the readable ones (finding 4); a removed env file is flushed as a deletion (finding 5).
**Files:**
- Create: `internal/host/relocationreleases.go`, `internal/host/relocationeffects.go`, `internal/host/relocationflush.go`, `internal/hostfiles/platform.go`
@@ -7302,7 +7363,7 @@ func TestRelocationReleaseReplaysOnlyRecordedPositions(t *testing.T) {
// verification or CompleteFence). The retry, same controller, no
// restart, resumes at the producers' drain with the recorded
// positions: no step of the release runs again.
- e, obs, calls, _ := effectsFixture(t)
+ e, obs, calls, seen := effectsFixture(t)
collect := e.Collect
e.Collect = func(ctx context.Context, r StopRequest, d StopDrainer) (StopResult, error) {
if _, err := d.DrainClaim(ctx, r, "fence"); err != nil {
@@ -7315,6 +7376,12 @@ func TestRelocationReleaseReplaysOnlyRecordedPositions(t *testing.T) {
}
*calls = nil
e.Collect = collect
+ // Round 8 finding 2: the roster moved on between the attempts
+ // (same boot, same controller); the retry still names the
+ // revision the stop was flushed under.
+ e.Identity = func(context.Context) (RelocationIdentity, error) {
+ return RelocationIdentity{Actor: hostbox.Actor{Box: "src", Enrollment: "enr", Revision: 5}, BootID: "boot", ControllerID: "ctl"}, nil
+ }
r, err := e.Release(context.Background(), obs)
if err != nil || r.FinalTXID != 41 || r.ManifestSeq != 8 {
t.Fatalf("retry: %+v %v", r, err)
@@ -7322,6 +7389,9 @@ func TestRelocationReleaseReplaysOnlyRecordedPositions(t *testing.T) {
if want := []string{"producers", "receipt"}; !slices.Equal(*calls, want) {
t.Fatalf("the retry ran %v, want %v (no flush, no record, no writers stop again)", *calls, want)
}
+ if seen.ExpectedDirectoryRevision != "4" || seen.BootID != "boot" || seen.ControllerID != "ctl" {
+ t.Fatalf("the retry asked root under another stop: %+v", *seen)
+ }
})
t.Run("after a restart the recorded identity asks root", func(t *testing.T) {
// The agent flushed and recorded under controller "ctl", root
@@ -8594,6 +8664,13 @@ func (e *RelocationEffects) Release(ctx context.Context, obs hostclaim.Observed)
if err != nil {
return hostclaim.Release{}, err
}
+ if flushed {
+ // The stop is the recorded one: root's fence and its inventory
+ // check name the directory revision the flush was made under, so
+ // a retry keeps it even when the roster has moved on since
+ // (round 8 finding 2), whether or not this is a recovery.
+ req.ExpectedDirectoryRevision = recorded.ExpectedDirectoryRevision
+ }
var d StopDrainer = RecoveryReleaseDrain{}
drain := &releaseDrain{e: e, obs: obs}
if flushed && (recorded.BootID != req.BootID || recorded.ControllerID != req.ControllerID) {
@@ -8604,7 +8681,7 @@ func (e *RelocationEffects) Release(ctx context.Context, obs hostclaim.Observed)
// marks it a recovery request (6b's authenticated Recover path,
// Task 8a), under which root accepts another tuple for this box's
// session and replays the receipt.
- req.BootID, req.ControllerID, req.ExpectedDirectoryRevision = recorded.BootID, recorded.ControllerID, recorded.ExpectedDirectoryRevision
+ req.BootID, req.ControllerID = recorded.BootID, recorded.ControllerID
} else {
if drain.inner, err = e.Producers(ctx, obs); err != nil {
return hostclaim.Release{}, err
@@ -10951,3 +11028,4 @@ Expected: exit 0. Exit 2 is pending: wait and read again. Exit 1: read the log a
- **Rev 6 guards**, each removed in turn and its test watched to fail: `Finish`'s repeated scoped cleanup before every clear (the real hostexec test's sweep after a failed cleanup clears); the participation journaled before the open; the journal listing's `.tmp` exclusion; a damaged committed entry keeping the readable ones; `Sweep` reporting `Retained`'s error; the env file's staged deletion; `replaceTargetSlot`'s `park-local` refusal; the worker's `park-local` exclusion; the chooser's `park-local` exclusion; `fenceBox`'s regional reassignment; Task 9a's serving-only check with HA off. Eleven in all.
- **Rev 7 guards**, each removed in turn and its test watched to fail: the `KCMP_FILES` shared-table check (the real kernel's unshared thread with the copied descriptor passes as the leader's); the `KCMP_FILE` description check (the fixture's shared table with another description passes); an unavailable kcmp failing closed (ENOSYS and EPERM pass instead of `ErrIncomplete`). Three in all.
- **Rev 8 guards**, each removed in turn and its test watched to fail: `Release` marking a same-controller retry with recorded positions as resumed; the resumed drain skipping straight to the producers' drain. Two in all.
+- **Rev 9 guards**, each removed in turn and its test watched to fail: `FenceExhaustedTarget` keeping a live source's `op.exec` and phase (the `starting` and `releasing` cases revoke it); `Release` keeping the recorded directory revision whenever positions are recorded (the same-controller retry asks at the new revision). Two in all.