docs: region HA stage B plan rev 5.3, Astra round 8
Ruling 82: the completion pass (discharge, hold release, owned-fence lift, mark) runs whenever claim mode is on, whatever CARLOS_REGION_HA says. With region HA off, or with the relocation owners unbound, NewRelocationRuntime builds a completion-only runtime that reads only this box's own registry rows. It steps nothing, converts nothing and writes nothing on a box that never completed a relocation. In claim mode the agent opens the claim assembly early, as the claim factory would, and always starts a pass. Tests: a failed mark followed by a restart with region HA off still finishes and marks; a region-HA-off box with no completion changes nothing. When a box can never run its pass, the operator path is the existing loss path, documented as checklist item 19 with no new console API or CLI. Each task was recompiled and retested in order in a throwaway copy, and the plan's code was extracted back out and matched the checked commits exactly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 file changed,
+273
−77
docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md+273 −77
diff --git a/docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md b/docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md| index 33a65168f..c6ec4499f 100644 |
| --- a/docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md |
| +++ b/docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md |
| @@ -1,4 +1,4 @@ |
| -# Region HA 1b-next Stage B: Relocation Wired Into the Claim Assembly Implementation Plan (rev 5.2) |
| +# Region HA 1b-next Stage B: Relocation Wired Into the Claim Assembly Implementation Plan (rev 5.3) |
| > **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. |
| @@ -8,7 +8,7 @@ |
| **Tech Stack:** Go 1.26, module `github.com/carlosframework/platform`; `release.Store`; `hostclaim`, `hostactors`, `hostfence`, `hostguard`, `hostexec`, `hostbox`, `hostrelocate`, `hostfiles`, `genselect`, `moves`, `lease`, `activator`, `registry`, `appconfig`, `edgesfile`, `instancekey`, `release`. |
| -**Spec:** `docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md` (rev 5.4 with §24): §5.3, §5.8, §6, §8. The 1b-next plan's rulings 1 to 27 stand; this plan's rulings continue the numbering at 28. Claim Plan 11 (`docs/superpowers/plans/2026-10-09-host-lifecycle-claim-11-region-ha-integration.md` on `origin/design-replica-ownership`, "Region HA integration and rollout", items 1 to 7) and the #236 answers (R1 to R10, C1 to C5, M1 to M3, J2, J3) are the contracts. Rev 5.2's code was compiled and run on this branch at b9a753f0 with `internal/instancekey` from `instance-key-build` Task 1 as built (e8487c3a; its API is unchanged since); file:line cites are to this branch. |
| +**Spec:** `docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md` (rev 5.4 with §24): §5.3, §5.8, §6, §8. The 1b-next plan's rulings 1 to 27 stand; this plan's rulings continue the numbering at 28. Claim Plan 11 (`docs/superpowers/plans/2026-10-09-host-lifecycle-claim-11-region-ha-integration.md` on `origin/design-replica-ownership`, "Region HA integration and rollout", items 1 to 7) and the #236 answers (R1 to R10, C1 to C5, M1 to M3, J2, J3) are the contracts. Rev 5.3's code was compiled and run on this branch at b9a753f0 with `internal/instancekey` from `instance-key-build` Task 1 as built (e8487c3a; its API is unchanged since); file:line cites are to this branch. |
| **Rev 5 in one paragraph.** Rev 5 is the build-ready consolidation of rev 4.2 against the pre-flight scan (`.superpowers/sdd/2026-10-10-region-ha-1b-next-stage-b/preflight-scan.md`, K1 to K25 and D1 to D9). Every task's code was written out, compiled and run task by task in a throwaway copy, and each task's text below is generated from its own commit there: its Files, its tests, its `-run` filter and its `git add` match its code. No task depends on a later task. Nothing in a task refers to a revision section; the revision layers (rev 2 to 4.2) are folded into the tasks, and their disposition tables are kept below as history. Every test has a body that asserts behaviour, and every function has its body. C10 is committed in Task 9 as region HA code behind the activation gate. Four promised tests are withdrawn with reasons (R5-2, R5-4), and one rev 1 change is withdrawn (R5-1); they are listed under "Unresolved in rev 5". |
| @@ -16,8 +16,16 @@ |
| **Rev 5.2 in one paragraph.** Astra's round 7 closed round 6's findings 1, 2 and 4 and left finding 3 with two P2s. Rev 5.2 fixes both. The completed relocation is finished from the claim's record on every pass, never from the row, with the mark last and every step idempotent (ruling 80). The discharge barrier is one function that both the shared `StartGuard` and the relocation start guard call, so a successor move, delete, purge or placement waits for it exactly as a relocation does (ruling 81; named change N13, claim ask C12). Every task was rebuilt and rerun in order. |
| +**Rev 5.3 in one paragraph.** Astra's round 8 closed round 7's findings and left one P2: with `CARLOS_REGION_HA` off after a failed mark, no runtime finished the completion and the barrier stayed. Ruling 82 (binding) makes the completion pass run whenever claim mode is on. With region HA off, or the relocation owners not bound, the agent runs a completion-only runtime that finishes a relocation already completed on the box and does nothing else. The operator path for a box that can never run its pass is the existing loss path, documented in the checklist (item 19). Only Task 10 changed; every task was rebuilt and rerun in order. |
| + |
| --- |
| +## Rev 5.3: Astra round 8 |
| + |
| +| # | Finding | Fix in rev 5.3 | Where | |
| +|---|---|---|---| |
| +| 1 (P2) | With `CARLOS_REGION_HA` off after a failed mark, `NewRelocationRuntime` returned nil, completion recovery never ran, and `StartGuard`'s barrier stayed for good. | Ruling 82. `NewRelocationRuntime` returns nil only with claim mode off. With region HA off, or the owners unbound, it is `newCompletionRuntime`: `Finish` (`relocationReleaseHold`: discharge, hold release, owned-fence lift, mark) over the hosts of this box's own registry rows, and no worker, participant or conversion. The agent opens the claim assembly in claim mode (as the claim factory would) and always starts a pass there. Tests: `TestCompletionPassFinishesWithRegionHAOff` (A to B completes, the mark's CAS fails, B restarts with region HA off, and the completion-only runtime lifts and marks so the barrier opens); `TestCompletionPassChangesNothingWithoutACompletion` (no claim, row, journal or fence changes on a box that never completed a relocation, including beside another box's completion); `TestRelocationRuntimeFollowsTheFlags`. Operator path when the box can never run its pass: checklist item 19 (no new console API or CLI). | 10; checklist | |
| + |
| ## Rev 5.2: Astra round 7 |
| | # | Finding | Fix in rev 5.2 | Where | |
| @@ -110,9 +118,9 @@ The labels and their order are rev 4.2's (8a, 8b, 8c, 8d, 9, 10, 11). What moved |
| | The lease-region test | 8d (`relocation_rev2_test.go`) | 10 | |
| | The hold-only reconcile change | 9 | withdrawn (R5-1) | |
| -## Per-task compile and test record (rev 5.2) |
| +## Per-task compile and test record (rev 5.3) |
| -Each task was committed in a throwaway copy of this branch (code as at b9a753f0; the branch has only plan commits since), on top of `internal/instancekey` from `instance-key-build` Task 1 as built (e8487c3a), in the order 8a, 8b, 8c, 8d, 9, 10 (rev 5.2), and checked at its own commit with `GOFLAGS=-buildvcs=false`, niced, and the socket tests run outside the sandbox with `TMPDIR` in a private `/tmp/k.XXXX`. Each task's code in this plan was then extracted from the text into a second copy and found identical, file for file, to the commit that was checked. |
| +Each task was committed in a throwaway copy of this branch (code as at b9a753f0; the branch has only plan commits since), on top of `internal/instancekey` from `instance-key-build` Task 1 as built (e8487c3a), in the order 8a, 8b, 8c, 8d, 9, 10 (rev 5.3), and checked at its own commit with `GOFLAGS=-buildvcs=false`, niced, and the socket tests run outside the sandbox with `TMPDIR` in a private `/tmp/k.XXXX`. Each task's code in this plan was then extracted from the text into a second copy and found identical, file for file, to the commit that was checked. |
| | Task | `go build ./...` | `go vet` (touched packages) | `gofmt -l` | Tests at the task's commit | |
| |---|---|---|---|---| |
| @@ -122,9 +130,9 @@ Each task was committed in a throwaway copy of this branch (code as at b9a753f0; |
| | 8c | clean | clean: host, hostbox, hostfiles, registry, provision | empty | `registry` and `hostfiles` pass in full; hostbox's adapter and census tests, provision's reconcile and route tests and host's install tests pass | |
| | 8d | clean | clean: host | empty | the owner, wake, attempt and root tests pass, with the moves probe-owner, Plan 9 D5, Plan 11 and claim-launch regressions | |
| | 9 | clean | clean: host, hostbox | empty | hostbox's reconcile tests and host's probe-stop (restart, failed marker write), hold-release, successor, discharge, handover, sweep, Plan 9, Plan 11 and handover regressions pass | |
| -| 10 | clean | clean: host, hostrelocate, edgesfile, console | empty | `hostrelocate` and `edgesfile` pass in full; the console's edges, eligibility, placement, region, executor and claim-runtime tests pass; 80 region HA host tests pass with the Plan 9, Plan 11, claim-launch, moves and handover regressions | |
| +| 10 | clean | clean: host, hostrelocate, edgesfile, console | empty | `hostrelocate` and `edgesfile` pass in full; the console's edges, eligibility, placement, region, executor and claim-runtime tests pass; 82 region HA host tests pass with the Plan 9, Plan 11, claim-launch, moves and handover regressions | |
| -At Task 10 the whole `internal/hostclaim`, `internal/hostops`, `internal/hostrelocate`, `internal/hostbox` and `internal/host` (47s) packages also pass, once, outside the sandbox (rev 5.2). The whole `internal/activator` package passed at rev 5.1 and rev 5.2 does not touch it; the console's passed at rev 5 and neither revision since touches it. At Task 10, `GOOS=darwin`, `GOOS=windows` and `GOARCH=arm64` builds of `./...` are clean, and so is `go vet` of host, hostrelocate, hostclaim, hostops and edgesfile for all three (and, at rev 5.1, of activator for darwin). Two windows vets fail at b9a753f0 already and this plan does not touch either cause: `./internal/hostbox/` (`plan9_fix2_minors_test.go:100`, `undefined: plan9D1Probe`) and `./internal/activator/` (`activator_test.go:4528`, `undefined: unix.Mkfifo`). |
| +At Task 10 the whole `internal/hostclaim`, `internal/hostops`, `internal/hostrelocate`, `internal/hostbox` and `internal/host` (47s) packages also pass, once, outside the sandbox (rev 5.3). The whole `internal/activator` package passed at rev 5.1 and rev 5.2 does not touch it; the console's passed at rev 5 and neither revision since touches it. At Task 10, `GOOS=darwin`, `GOOS=windows` and `GOARCH=arm64` builds of `./...` are clean, and so is `go vet` of host, hostrelocate, hostclaim, hostops and edgesfile for all three (and, at rev 5.1, of activator for darwin). Two windows vets fail at b9a753f0 already and this plan does not touch either cause: `./internal/hostbox/` (`plan9_fix2_minors_test.go:100`, `undefined: plan9D1Probe`) and `./internal/activator/` (`activator_test.go:4528`, `undefined: unix.Mkfifo`). |
| ## Disposition history (rev 2 to rev 4.2) |
| @@ -392,6 +400,7 @@ Rulings 28 to 41 are rev 1's; 42 to 54 rev 2's; 55 to 64 rev 3's; 65 to 69 rev 4 |
| 79. **Task 10b is mandatory before activation** (Astra round 6 on R5-3). The source's row is held at its fence's close by Task 10b; relocation is not switched on anywhere until it has landed and its tests pass. *Cost if wrong:* none while dormant. |
| 80. **A completion is finished from its record, the mark last** (Astra round 7 finding 1; amends 76). The pass hands `Finish` every serving claim whose `completed_relocation` names this box and is not `Discharged`, on every pass and independent of the row. `relocationReleaseHold`: while the row is held for the op, the holder must be the probe session, then the discharge, then `ReleaseHold`; then `LiftFenceAs` under the persisted owner; then `MarkCompletedDischarged`. Once the row is not held for the op, the discharge has happened (only this function releases a completed op's hold), so a replay skips it and lifts and marks. The journal sweep keeps the op's journals, with the fence owner, until the mark. *Cost if wrong:* if the discharge could be undone after the hold went, a replay would mark a box whose slot is not discharged and a successor's `Prepare` would answer `ErrLost`, which is the stuck state the mark prevents. Nothing in main releases a relocating hold, so this would take a new writer of `hold_op`. |
| 81. **One discharge barrier for every start** (Astra round 7 finding 2; extends 77). `completionBarrier` is called by `RelocationStartGuard` and by `StartGuard` (N13, claim ask C12), so a move, delete, purge, placement or relocation from serving waits for the mark. The exemptions are a completion box on `lost` and a failover citing that box's fence. *Cost if wrong:* an operator's move or delete of a host that has just relocated waits one pass of the target's finish; if the target cannot finish (its discharge refused), every start waits until it can or the box is fenced. |
| +82. **The completion pass runs whenever claim mode is on** (Astra round 8; binding, in the ledger). The completion pass (discharge, hold release, owned-fence lift, mark) cleans up a relocation that already happened, so it runs whenever the box holds an undischarged completion naming it, whatever `CARLOS_REGION_HA` says. Only starting relocations, the worker's and participant's passes and the new owners need the flag. With the flag off, or the owners unbound, the agent runs the smallest runtime that does this alone (`newCompletionRuntime`). It reads only the claims of the box's own registry rows and writes nothing on a box that never completed a relocation. When the box can never run its pass, the operator path is the existing loss path: terminate the box, or have it declared lost. Either one lifts the barrier through its exemption (checklist item 19); no console API or CLI is added. *Cost if wrong:* every claim-mode box reads its own hosts' claims once per pass, about every 30 seconds. |
| ## L21: where the platform-provisioned `CARLOS_INSTANCE_KEY` belongs |
| @@ -12112,23 +12121,23 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" |
| ### Task 10: The pass behind `CARLOS_REGION_HA`, the shared edges read, and the rollout checklist |
| -Every box with the claim and region HA on, and moves' C8 provider bound, replays its region's relocations: per host, the participant, then the worker, then the completion this box has not yet finished, found from the claim's record (ruling 80), so a passed probe completes in one pass (ruling 49); a landing whose target is fenced is converted when its move committed (J3); the journals of ended operations are swept (ruling 61). The pass starts no relocation: `Worker.Start` is the operator's and spec 3's placer's, so the worker's `Admission` and `Census` refuse here (D8: rev 1's `relocationAppAdmission`, `localBoxReport` and `hostfloor.Read` belong to that starter, not to this pass). |
| +Every box with the claim and region HA on, and moves' C8 provider bound, replays its region's relocations: per host, the participant, then the worker, then the completion this box has not yet finished, found from the claim's record (ruling 80), so a passed probe completes in one pass (ruling 49); a landing whose target is fenced is converted when its move committed (J3); the journals of ended operations are swept (ruling 61). With claim mode on and region HA off, or the owners not bound, the agent runs the completion-only runtime instead, which only finishes a relocation already completed on the box (ruling 82). The pass starts no relocation: `Worker.Start` is the operator's and spec 3's placer's, so the worker's `Admission` and `Census` refuse here (D8: rev 1's `relocationAppAdmission`, `localBoxReport` and `hostfloor.Read` belong to that starter, not to this pass). |
| The agent registers the source with the roster directory the claim's owners read, and opens the claim assembly before the wiring so the pass binds to the same one (`ensureProductionClaimAssembly`). N5 is in `openClaimRelocationOwners`, inside its region HA gate, before the source opens (K17). `relocationAssemblyConfig` takes the discharge and the opt root (K16). `Worker.Relocatable` is `relocationRelocatable`, an adapter over `relocationRecord` (K24). The chooser's fingerprint is the box's own (`edgesFileSecretsFP`). The console's live edges read moves to `internal/edgesfile` unchanged (`ReadSnapshot`, `ReadLiveSnapshot`, `Snapshot.BoxChoice`, `LiveSnapshot`), and the console reads through aliases. |
| **Files:** |
| - Create: `internal/console/claim_runtime_chooser_test.go`, `internal/edgesfile/snapshot.go`, `internal/edgesfile/snapshot_test.go`, `internal/host/relocatepass.go`, `internal/host/relocatepass_test.go`, `internal/host/relocationfinish_unix_test.go`, `internal/host/relocationlanding.go`, `internal/host/relocationlanding_test.go`, `internal/hostrelocate/pin_versioned_test.go` |
| -- Modify: `internal/console/apiinstances.go`, `internal/console/boxchoice.go`, `internal/console/boxselect.go`, `internal/console/claim_runtime.go` (`Executors`), `internal/console/instancesplacement.go`, `internal/console/regioncandidates.go`, `internal/console/regioncandidates_test.go` (`snap.EvidenceComplete()`), `internal/console/regiondefault.go`, `internal/host/agent.go` (register the source; start the pass), `internal/host/claimparticipation_relocation.go`, `internal/host/claimrelocation.go` (N5), `internal/hostrelocate/worker.go`, `internal/hostrelocate/worker_bounds_test.go` |
| +- Modify: `internal/console/apiinstances.go`, `internal/console/boxchoice.go`, `internal/console/boxselect.go`, `internal/console/claim_runtime.go` (`Executors`), `internal/console/instancesplacement.go`, `internal/console/regioncandidates.go`, `internal/console/regioncandidates_test.go` (`snap.EvidenceComplete()`), `internal/console/regiondefault.go`, `internal/host/agent.go` (register the source with region HA on; open the claim assembly in claim mode; start the pass, completion-only with region HA off), `internal/host/claimparticipation_relocation.go`, `internal/host/claimrelocation.go` (N5), `internal/hostrelocate/worker.go`, `internal/hostrelocate/worker_bounds_test.go` |
| **Interfaces:** |
| - Consumes: `hostrelocate.Worker`, `Participant`, `RegionChooser`, `StorePinner`; `hostfence.ClaimFenceAuthority`; `StopReceiptReleases`, `RelocationReleaseReceipts`; `hostops.ExecutorChooser`, `ClaimLoads`; `release.Channel`, `NewS3Store`, `BumpSerial`, `ParsePublicKey`; `lease.Acquire`, `NewVersionedS3`; `genselect.Select`; `provision.GetRecord`, `MoveState`, `MoveCommitted`; `moves.CommitScopes`; `hostclaim.ConvertCommittedLanding`, `CommittedLandingGuard`; `boxvolumes.List`, `Discharge`; `hostfloor.ReadEnrollment`; `appconfig.ParsePrivateKey`; `edgesFileSecretsFP`, `MovesRecordsFrom`; `ensureProductionClaimAssembly`, `claimEntry`; `status.LoadBoxesComplete`, `BoxStale`. |
| -- Produces: `relocatePassEnabled`, `RelocationRuntime` (`Run`, `once`, `finishCompletion`, `alert`; no registry), `relocationStepper`, `relocationStores`, `openRelocationStores`, `relocationLeases`, `relocationAssemblyConfig`, `channelRelease`, `volumeDischarge`, `relocationRelocatable`, `relocationStartRefused`, `relocationRuntimeInputs`, `productionRelocationRuntimeInputs`, `NewRelocationRuntime`, `wakeDispatch`, `convertFencedLanding`, `registerRelocationSource`, `startRelocationPass`; `(*productionClaimLocal).boundAssembly` (N1); `CommittedLandingRecords`; `hostrelocate.Worker.ConvertLanding` (ruling 78), `newestFenceObservation` and the no-target bound; `edgesfile.PlacementView`, `Snapshot`, `ReadSnapshot`, `ReadLiveSnapshot`, `LiveSnapshot`; `ProductionAuthority.Executors` from the console's chooser. |
| +- Produces: `RelocationRuntime` (`Run`, `once`, `hosts`, `finishCompletion`, `alert`; `Hosts`; no registry), `newCompletionRuntime` (ruling 82), `relocationStepper`, `relocationStores`, `openRelocationStores`, `relocationLeases`, `relocationAssemblyConfig`, `channelRelease`, `volumeDischarge`, `relocationRelocatable`, `relocationStartRefused`, `relocationRuntimeInputs`, `productionRelocationRuntimeInputs`, `NewRelocationRuntime`, `wakeDispatch`, `convertFencedLanding`, `registerRelocationSource`, `startRelocationPass`; `(*productionClaimLocal).boundAssembly` (N1); `CommittedLandingRecords`; `hostrelocate.Worker.ConvertLanding` (ruling 78), `newestFenceObservation` and the no-target bound; `edgesfile.PlacementView`, `Snapshot`, `ReadSnapshot`, `ReadLiveSnapshot`, `LiveSnapshot`; `ProductionAuthority.Executors` from the console's chooser. |
| - [ ] **Step 1: Write the failing tests** |
| The pass tests drive `once` over a DirStore control bucket with recording steppers; `TestRelocatePassFinishesACompletionFromItsRecord` drives it over the production `Finish` (`relocationReleaseHold`) with a real registry and the real activator's owned fence, through a failed discharge, a failed mark CAS, a lift that never ran and a restart (ruling 80); the steps test holds a landing claim beside the relocating ones and asserts only it is offered to `Convert` and nothing steps it. `TestRelocatePassConvertsAFencedCommittedLanding` runs the production conversion over a real landing claim, a signed roster, a termination fence and the authoritative record, committed and not; `TestRelocatePassConversionRefusesAnUnauthorizedWorker` runs it as a revoked, retired, fenced, wrongly enrolled or other-region worker and finds the claim unwritten each time (ruling 78). `TestRelocationReleaseIsTheChannelPointer` ships and promotes real releases with a generated root key. `TestRelocationPinnerSelectsOverTheVersionedBucket` (hostrelocate) runs the pinner over `genselect.Select` and a versioned bucket holding a marker, a snapshot and a level-0 file. Rev 1's `TestRelocatePassStaysOffWhileThePreflightRefuses` is not a pass test in rev 5: the preflight is the participant's (Task 8a, `TestParticipantCountsNoAttemptWhileRootRefusesTheKind`). Finding 12's test (`TestRelocationLeasesSignForTheCloudRegion`) is folded into `TestRelocationRuntimeOpensADataBucketStore` (K6). |
| -New tests: `TestCreateDoorUsesTheChooserOnlyWithRegionHAOn`, `TestLiveSnapshotJoinsEdgesFilesWithLiveness`, `TestRelocatePassIsOffUnlessBothFlagsAreOn`, `TestRelocatePassStepsEveryRelocationAndSweeps`, `TestRelocatePassAlertsOnceWhileAConversionWaitsForATarget`, `TestRelocatePassStopsCompletesAndReleasesInOnePass`, `TestRelocatePassFinishesACompletionFromItsRecord`, `TestCommittedLandingRecordsAdmitOnlyACommittedMove`, `TestRelocationReleaseIsTheChannelPointer`, `TestDischargeIsUnqualifiedWithoutEnrolledAt`, `TestRelocationRuntimeOpensADataBucketStore`, `TestRelocationBindsWithARegisteredMovesSource`, `TestRelocatePassConvertsAFencedCommittedLanding`, `TestRelocatePassConversionRefusesAnUnauthorizedWorker`, `TestRelocationPinnerSelectsOverTheVersionedBucket`, `TestWorkerBoundsAConversionWaitingForATarget`. |
| +New tests: `TestCreateDoorUsesTheChooserOnlyWithRegionHAOn`, `TestLiveSnapshotJoinsEdgesFilesWithLiveness`, `TestRelocationRuntimeFollowsTheFlags`, `TestCompletionPassChangesNothingWithoutACompletion`, `TestCompletionPassFinishesWithRegionHAOff`, `TestRelocatePassStepsEveryRelocationAndSweeps`, `TestRelocatePassAlertsOnceWhileAConversionWaitsForATarget`, `TestRelocatePassStopsCompletesAndReleasesInOnePass`, `TestRelocatePassFinishesACompletionFromItsRecord`, `TestCommittedLandingRecordsAdmitOnlyACommittedMove`, `TestRelocationReleaseIsTheChannelPointer`, `TestDischargeIsUnqualifiedWithoutEnrolledAt`, `TestRelocationRuntimeOpensADataBucketStore`, `TestRelocationBindsWithARegisteredMovesSource`, `TestRelocatePassConvertsAFencedCommittedLanding`, `TestRelocatePassConversionRefusesAnUnauthorizedWorker`, `TestRelocationPinnerSelectsOverTheVersionedBucket`, `TestWorkerBoundsAConversionWaitingForATarget`. |
| `internal/console/claim_runtime_chooser_test.go` (new): |
| @@ -12255,10 +12264,12 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/boxvolumes" |
| "github.com/carlosframework/platform/internal/hostactors" |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| "github.com/carlosframework/platform/internal/hostrelocate" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/lease/leasetest" |
| @@ -12316,14 +12327,21 @@ func (s stepRecorder) Step(_ context.Context, host string) (hostclaim.Observed, |
| return hostclaim.Observed{}, s.err |
| } |
| -// Spec §8 step 2: the pass runs only with CARLOS_HOST_CLAIM and |
| -// CARLOS_REGION_HA both on, and NewRelocationRuntime builds nothing |
| -// otherwise. |
| -func TestRelocatePassIsOffUnlessBothFlagsAreOn(t *testing.T) { |
| +// Spec §8 step 2 and ruling 82: nothing runs with CARLOS_HOST_CLAIM off. |
| +// With claim mode on, CARLOS_REGION_HA off, or the relocation owners not |
| +// bound, the runtime is the completion-only one: it steps nothing, |
| +// converts nothing and reads only this box's registry rows. |
| +func TestRelocationRuntimeFollowsTheFlags(t *testing.T) { |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + local := &productionClaimLocal{assembly: &ClaimAssembly{Guard: hostguard.Guard{DataDir: t.TempDir()}}} |
| for _, tc := range []struct { |
| - claim, ha string |
| - on bool |
| - }{{"", "", false}, {"1", "", false}, {"", "1", false}, {"1", "1", true}} { |
| + claim, ha string |
| + completion bool |
| + }{{"", "", false}, {"", "1", false}, {"1", "", true}, {"1", "1", true}} { |
| getenv := func(k string) string { |
| switch k { |
| case "CARLOS_HOST_CLAIM": |
| @@ -12333,18 +12351,89 @@ func TestRelocatePassIsOffUnlessBothFlagsAreOn(t *testing.T) { |
| } |
| return "" |
| } |
| - if got := relocatePassEnabled(getenv); got != tc.on { |
| - t.Fatalf("claim %q region HA %q: %v", tc.claim, tc.ha, got) |
| + r, err := NewRelocationRuntime(t.Context(), getenv, release.NewDirStore(t.TempDir()), local, ClaimProviders{}, reg, &activator.Service{}, relocationRuntimeInputs{}) |
| + if err != nil { |
| + t.Fatalf("claim %q region HA %q: %v", tc.claim, tc.ha, err) |
| } |
| - if !tc.on { |
| - r, err := NewRelocationRuntime(t.Context(), getenv, release.NewDirStore(t.TempDir()), &productionClaimLocal{}, ClaimProviders{}, nil, nil, relocationRuntimeInputs{}) |
| - if r != nil || err != nil { |
| - t.Fatalf("claim %q region HA %q: built %v %v", tc.claim, tc.ha, r, err) |
| + if !tc.completion { |
| + if r != nil { |
| + t.Fatalf("claim %q region HA %q: built %+v", tc.claim, tc.ha, r) |
| } |
| + continue |
| + } |
| + // With region HA on, the owners are not bound here (no wake |
| + // dispatcher), so this is the completion-only runtime too. |
| + if r == nil || r.Worker != nil || r.Participant != nil || r.Convert != nil || r.Finish == nil || r.Hosts == nil { |
| + t.Fatalf("claim %q region HA %q: not a completion-only runtime: %+v", tc.claim, tc.ha, r) |
| } |
| } |
| } |
| +// Ruling 82: on a box with region HA off that never completed a |
| +// relocation, the completion pass writes nothing. Its hosts are its own |
| +// rows; a serving claim with no completion is left alone, and so is one |
| +// whose completion names another box. |
| +func TestCompletionPassChangesNothingWithoutACompletion(t *testing.T) { |
| + ctx := t.Context() |
| + store := release.NewDirStore(t.TempDir()) |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + passClaim(t, store, "a.example", hostclaim.Op{}) |
| + passClaim(t, store, "b.example", hostclaim.Op{}) |
| + completeOn(t, store, "b.example", "rel-b", "spa", false) |
| + passClaim(t, store, "c.example", hostclaim.Op{}) // no row here |
| + for _, host := range []string{"a.example", "b.example"} { |
| + if err := reg.Upsert(registry.Route{Host: host, Status: "ready", Socket: "/run/" + host + ".sock"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + before := map[string]string{} |
| + for _, host := range []string{"a.example", "b.example", "c.example"} { |
| + obs, err := hostclaim.Read(ctx, store, host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + before[host] = obs.ETag |
| + } |
| + rowsBefore, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + data := t.TempDir() |
| + local := &productionClaimLocal{assembly: &ClaimAssembly{Claims: store, Guard: hostguard.Guard{DataDir: data}, stopLocal: func(context.Context) (claimStopLocal, error) { |
| + return claimStopLocal{Actor: hostbox.Actor{Box: "dst"}}, nil |
| + }}} |
| + claimOnly := func(k string) string { |
| + if k == "CARLOS_HOST_CLAIM" { |
| + return "1" |
| + } |
| + return "" |
| + } |
| + svc := &activator.Service{} |
| + r, err := NewRelocationRuntime(ctx, claimOnly, store, local, ClaimProviders{}, reg, svc, relocationRuntimeInputs{}) |
| + if err != nil || r == nil { |
| + t.Fatal(err) |
| + } |
| + r.once(ctx, store) |
| + for host, etag := range before { |
| + if obs, err := hostclaim.Read(ctx, store, host); err != nil || obs.ETag != etag { |
| + t.Fatalf("%s was written: %v", host, err) |
| + } |
| + } |
| + if rows, err := reg.List(); err != nil || !reflect.DeepEqual(rows, rowsBefore) { |
| + t.Fatalf("the registry changed: %v", err) |
| + } |
| + if _, err := os.Stat(filepath.Join(data, "relocate")); !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("the pass made journals: %v", err) |
| + } |
| + if svc.Fenced("a.example") || svc.Fenced("b.example") { |
| + t.Fatal("the pass fenced a host") |
| + } |
| +} |
| + |
| // One pass steps every relocating claim (participant, then worker), hands |
| // every landing to the J3 conversion, leaves serving claims alone, and |
| // sweeps the journals of operations that are over (ruling 61). |
| @@ -12651,10 +12740,17 @@ func TestRelocationBindsWithARegisteredMovesSource(t *testing.T) { |
| if closeSource, err := openClaimRelocationOwners(ctx, plan11Env("1", ""), source(), release.NewDirStore(t.TempDir()), &fakeVersioned{}, actor, &off); err != nil || closeSource != nil || !off.Relocation.zero() { |
| t.Fatalf("region HA off: %+v %v", off.Relocation, err) |
| } |
| - // No runtime without the bound owners. |
| - local := &productionClaimLocal{assembly: &ClaimAssembly{Wake: &ClaimWakeAssembly{Inputs: ClaimOwnerDispatch{}}}} |
| - if r, err := NewRelocationRuntime(ctx, plan11Env("1", "1"), release.NewDirStore(t.TempDir()), local, ClaimProviders{}, nil, nil, relocationRuntimeInputs{}); r != nil || err != nil { |
| - t.Fatalf("a runtime without the relocation owners: %v %v", r, err) |
| + // Without the bound owners there is no relocation pass, only the |
| + // completion-only runtime (ruling 82). |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer reg.Close() |
| + local := &productionClaimLocal{assembly: &ClaimAssembly{Wake: &ClaimWakeAssembly{Inputs: ClaimOwnerDispatch{}}, Guard: hostguard.Guard{DataDir: t.TempDir()}}} |
| + r, err := NewRelocationRuntime(ctx, plan11Env("1", "1"), release.NewDirStore(t.TempDir()), local, ClaimProviders{}, reg, &activator.Service{}, relocationRuntimeInputs{}) |
| + if err != nil || r == nil || r.Worker != nil || r.Participant != nil || r.Finish == nil { |
| + t.Fatalf("a runtime without the relocation owners: %+v %v", r, err) |
| } |
| } |
| ``` |
| @@ -12679,6 +12775,7 @@ import ( |
| "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/hostbox" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -12713,6 +12810,7 @@ type finishWorld struct { |
| discharge error |
| runtime *RelocationRuntime |
| dataDir string |
| + assembly *ClaimAssembly |
| } |
| const finishHost = "shop.example" |
| @@ -12758,9 +12856,10 @@ func newFinishWorld(t *testing.T) *finishWorld { |
| if _, err := w.act.svc.FenceAndStopAs(fctx, finishHost, 12, owner); err != nil && !errors.Is(err, activator.ErrStopUnproven) { |
| t.Fatal(err) |
| } |
| - a := ClaimAssembly{Claims: w.store, stopLocal: func(context.Context) (claimStopLocal, error) { |
| + a := ClaimAssembly{Claims: w.store, Guard: hostguard.Guard{DataDir: w.dataDir}, stopLocal: func(context.Context) (claimStopLocal, error) { |
| return claimStopLocal{Actor: hostbox.Actor{Box: "dst"}}, nil |
| }} |
| + w.assembly = &a |
| cfg := RelocationAssemblyConfig{Registry: w.reg, Activator: w.act, DataDir: w.dataDir, Discharge: func(context.Context, hostclaim.Observed) error { |
| w.discharged++ |
| return w.discharge |
| @@ -12857,6 +12956,33 @@ func TestRelocatePassFinishesACompletionFromItsRecord(t *testing.T) { |
| w.done(1) |
| }) |
| } |
| + |
| +// Astra round 8 (ruling 82): A to B completes, the mark's CAS fails, and B |
| +// restarts with CARLOS_REGION_HA off. Claim mode alone builds the |
| +// completion-only runtime, which steps nothing, and its pass still lifts |
| +// and marks, so the barrier opens. |
| +func TestCompletionPassFinishesWithRegionHAOff(t *testing.T) { |
| + w := newFinishWorld(t) |
| + w.store.fail = 1 |
| + w.pass() |
| + if ok, barrier := w.marked(); ok || w.held() || !errors.Is(barrier, hostclaim.ErrPendingChore) { |
| + t.Fatalf("the first pass marked or kept the hold: marked=%v held=%v barrier=%v", ok, w.held(), barrier) |
| + } |
| + w.act.svc = &activator.Service{} // the restart |
| + claimOnly := func(k string) string { |
| + if k == "CARLOS_HOST_CLAIM" { |
| + return "1" |
| + } |
| + return "" |
| + } |
| + r, err := NewRelocationRuntime(t.Context(), claimOnly, w.store, &productionClaimLocal{assembly: w.assembly}, ClaimProviders{}, w.reg, w.act, relocationRuntimeInputs{}) |
| + if err != nil || r == nil || r.Worker != nil || r.Participant != nil || r.Convert != nil || r.Finish == nil { |
| + t.Fatalf("not a completion-only runtime: %+v %v", r, err) |
| + } |
| + w.runtime = r |
| + w.pass() |
| + w.done(1) |
| +} |
| ``` |
| `internal/host/relocationlanding_test.go` (new): |
| @@ -13258,7 +13384,7 @@ func TestRelocationPinnerSelectsOverTheVersionedBucket(t *testing.T) { |
| Run: |
| ```bash |
| -nice -n 19 go test ./internal/host/ -count=1 -run '^(TestRelocatePass|TestCommittedLanding|TestRelocationReleaseIsTheChannel|TestDischargeIsUnqualified|TestRelocationRuntimeOpens|TestRelocationBindsWith)' |
| +nice -n 19 go test ./internal/host/ -count=1 -run '^(TestRelocatePass|TestRelocationRuntimeFollows|TestCompletionPass|TestCommittedLanding|TestRelocationReleaseIsTheChannel|TestDischargeIsUnqualified|TestRelocationRuntimeOpens|TestRelocationBindsWith)' |
| nice -n 19 go test ./internal/edgesfile/ ./internal/hostrelocate/ -run 'LiveSnapshot|TestRelocationPinnerSelects|TestWorkerBoundsAConversion' -count=1 |
| ``` |
| @@ -13972,13 +14098,6 @@ import ( |
| "github.com/carlosframework/platform/internal/termfence" |
| ) |
| -// relocatePassEnabled: the claim and region HA both on (spec §8 step 2). |
| -// The runtime is also built only when the relocation owners bound, which |
| -// needs moves' C8 provider (ruling 53). |
| -func relocatePassEnabled(getenv func(string) string) bool { |
| - return hostwrite.Enabled(getenv) && regionHAEnabled(getenv) |
| -} |
| - |
| // RelocationRuntime is one agent's relocation: its worker, participant and |
| // effects, built once the claim assembly is bound. It replays relocations; |
| // it starts none (Worker.Start is the operator's and the placer's, not |
| @@ -13992,6 +14111,11 @@ type RelocationRuntime struct { |
| // marked discharged (ruling 80). |
| Convert func(context.Context, release.Store, hostclaim.Observed) error |
| Finish func(ctx context.Context, host, op string) error |
| + // Hosts, when set, is the hosts a pass reads instead of every claim |
| + // in the bucket: the completion-only runtime's (ruling 82) are this |
| + // box's own registry rows, since a completion this box can finish |
| + // always has its row here. |
| + Hosts func() ([]string, error) |
| DataDir string // the managed data root: relocate/ journals (N9) |
| alerted sync.Map // host\x00cause: one alert per host and cause (rulings 35, 36) |
| } |
| @@ -14153,19 +14277,25 @@ func productionRelocationRuntimeInputs(getenv func(string) string, optDir string |
| } |
| // NewRelocationRuntime builds the agent's relocation runtime from the bound |
| -// claim assembly. It returns (nil, nil) unless CARLOS_HOST_CLAIM and |
| -// CARLOS_REGION_HA are on and the relocation owners bound into the wake's |
| -// dispatcher (ruling 53: they need moves' C8 provider). |
| +// claim assembly. It returns (nil, nil) unless CARLOS_HOST_CLAIM is on. |
| +// With CARLOS_REGION_HA off, or the relocation owners not bound into the |
| +// wake's dispatcher (ruling 53: they need moves' C8 provider), it is the |
| +// completion-only runtime (ruling 82): a relocation that already completed |
| +// on this box is still finished. Only with both on and the owners bound is |
| +// it the full pass. |
| func NewRelocationRuntime(ctx context.Context, getenv func(string) string, store release.Store, local *productionClaimLocal, providers ClaimProviders, reg *registry.Registry, act RelocationActivator, in relocationRuntimeInputs) (*RelocationRuntime, error) { |
| - if !relocatePassEnabled(getenv) || local == nil { |
| + if !hostwrite.Enabled(getenv) || local == nil { |
| return nil, nil |
| } |
| + if !regionHAEnabled(getenv) { |
| + return newCompletionRuntime(local, reg, act) |
| + } |
| a := local.boundAssembly() |
| if a == nil { |
| return nil, hostwrite.ErrUnavailable |
| } |
| if d, ok := wakeDispatch(a); !ok || d.Relocation == nil { |
| - return nil, nil |
| + return newCompletionRuntime(local, reg, act) |
| } |
| if a.RetirementAuthority == nil || store == nil || reg == nil || act == nil || len(in.Pub) != ed25519.PublicKeySize || in.Enrollment.Box == "" { |
| return nil, hostwrite.ErrUnavailable |
| @@ -14226,6 +14356,33 @@ func NewRelocationRuntime(ctx context.Context, getenv func(string) string, store |
| return &RelocationRuntime{Worker: worker, Participant: participant, Box: in.Enrollment.Box, Convert: convertFencedLanding(worker, store), Finish: effects.Finish, DataDir: cfg.DataDir}, nil |
| } |
| +// newCompletionRuntime is ruling 82: the smallest runtime that finishes a |
| +// relocation already completed on this box (discharge, hold release, |
| +// owned-fence lift, mark; relocationReleaseHold) and does nothing else. It |
| +// steps no relocation, converts no landing and starts nothing, and it |
| +// reads only the claims of this box's own registry rows, so a box that |
| +// never completed a relocation reads its own hosts' claims and writes |
| +// nothing. |
| +func newCompletionRuntime(local *productionClaimLocal, reg *registry.Registry, act RelocationActivator) (*RelocationRuntime, error) { |
| + a := local.boundAssembly() |
| + if a == nil || reg == nil || act == nil || a.Guard.DataDir == "" { |
| + return nil, hostwrite.ErrUnavailable |
| + } |
| + cfg := RelocationAssemblyConfig{Registry: reg, Activator: act, DataDir: a.Guard.DataDir, Discharge: local.DischargeCompleted, Now: time.Now} |
| + hosts := func() ([]string, error) { |
| + rows, err := reg.List() |
| + if err != nil { |
| + return nil, err |
| + } |
| + out := make([]string, 0, len(rows)) |
| + for _, row := range rows { |
| + out = append(out, row.Host) |
| + } |
| + return out, nil |
| + } |
| + return &RelocationRuntime{Finish: a.relocationReleaseHold(cfg), Hosts: hosts, DataDir: a.Guard.DataDir}, nil |
| +} |
| + |
| // wakeDispatch is the wake's owner dispatcher, as Build bound it. |
| func wakeDispatch(a *ClaimAssembly) (ClaimOwnerDispatch, bool) { |
| if a == nil || a.Wake == nil { |
| @@ -14259,16 +14416,12 @@ func (r *RelocationRuntime) Run(ctx context.Context, store release.Store, serial |
| // found from the claim's record (ruling 80). A landing whose target is |
| // fenced is converted when its move committed (J3). |
| func (r *RelocationRuntime) once(ctx context.Context, store release.Store) { |
| - keys, err := store.List(ctx, "control/hosts/") |
| + hosts, err := r.hosts(ctx, store) |
| if err != nil { |
| - log.Printf("relocate: list claims: %v", err) |
| + log.Printf("relocate: list hosts: %v", err) |
| return |
| } |
| - for _, key := range keys { |
| - host, ok := strings.CutSuffix(strings.TrimPrefix(key, "control/hosts/"), ".json") |
| - if !ok || strings.Contains(host, "/") { |
| - continue |
| - } |
| + for _, host := range hosts { |
| obs, err := hostclaim.Read(ctx, store, host) |
| if err != nil || obs.Absent { |
| continue |
| @@ -14281,11 +14434,15 @@ func (r *RelocationRuntime) once(ctx context.Context, store release.Store) { |
| } |
| } |
| case hostclaim.KindRelocating: |
| - if _, err := r.Participant.Step(ctx, host); err != nil && !errors.Is(err, hostrelocate.ErrNotYet) { |
| - r.alert(host, r.Box, err) |
| + if r.Participant != nil { |
| + if _, err := r.Participant.Step(ctx, host); err != nil && !errors.Is(err, hostrelocate.ErrNotYet) { |
| + r.alert(host, r.Box, err) |
| + } |
| } |
| - if _, err := r.Worker.Step(ctx, host); err != nil && !errors.Is(err, hostrelocate.ErrNotYet) { |
| - r.alert(host, "worker", err) |
| + if r.Worker != nil { |
| + if _, err := r.Worker.Step(ctx, host); err != nil && !errors.Is(err, hostrelocate.ErrNotYet) { |
| + r.alert(host, "worker", err) |
| + } |
| } |
| } |
| r.finishCompletion(ctx, store, host) |
| @@ -14298,6 +14455,25 @@ func (r *RelocationRuntime) once(ctx context.Context, store release.Store) { |
| } |
| } |
| +// hosts is every claim in the bucket, or Hosts when it is set. |
| +func (r *RelocationRuntime) hosts(ctx context.Context, store release.Store) ([]string, error) { |
| + if r.Hosts != nil { |
| + return r.Hosts() |
| + } |
| + keys, err := store.List(ctx, "control/hosts/") |
| + if err != nil { |
| + return nil, err |
| + } |
| + var out []string |
| + for _, key := range keys { |
| + host, ok := strings.CutSuffix(strings.TrimPrefix(key, "control/hosts/"), ".json") |
| + if ok && !strings.Contains(host, "/") { |
| + out = append(out, host) |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| // finishCompletion is ruling 80: a serving claim whose completed |
| // relocation names this box and is not yet marked discharged is handed to |
| // Finish (the hold release, relocationReleaseHold) on every pass, read |
| @@ -14314,7 +14490,9 @@ func (r *RelocationRuntime) finishCompletion(ctx context.Context, store release. |
| return |
| } |
| done, ok := hostclaim.ReadCompletedRelocation(obs.Claim) |
| - if !ok || done.Discharged || done.Stop.Box != r.Box { |
| + if !ok || done.Discharged || (r.Box != "" && done.Stop.Box != r.Box) { |
| + // The completion-only runtime has no roster read and so no Box; |
| + // Finish checks the claim serves on this box before anything. |
| return |
| } |
| if err := r.Finish(ctx, host, done.Op); err != nil { |
| @@ -14365,11 +14543,13 @@ func registerRelocationSource(claimCtx context.Context, dataDir string) error { |
| } |
| // startRelocationPass builds the relocation runtime from the claim entry's |
| -// assembly and starts its loop. Nil when region HA is off, the relocation |
| -// owners did not bind (no C8 provider), there is no activator, or the |
| -// runtime cannot be built (logged; nothing relocates on this box). |
| +// assembly and starts its loop. With claim mode on it always starts one: |
| +// the full pass with region HA on and the owners bound, otherwise the |
| +// completion-only pass (ruling 82). Nil with claim mode off, with no |
| +// activator, or when not even the completion pass can be built (logged). |
| func startRelocationPass(agentCtx, claimCtx context.Context, claims *ClaimWiring, reg *registry.Registry, svc *activator.Service) *RelocationRuntime { |
| - if !relocatePassEnabled(claimEntryEnv(claimCtx)) || claims == nil || svc == nil { |
| + getenv := claimEntryEnv(claimCtx) |
| + if !hostwrite.Enabled(getenv) || claims == nil || svc == nil { |
| return nil |
| } |
| entry, _ := claimCtx.Value(claimEntryKey{}).(claimEntry) |
| @@ -14381,17 +14561,24 @@ func startRelocationPass(agentCtx, claimCtx context.Context, claims *ClaimWiring |
| log.Printf("agent: region HA: relocation pass not started: the assembly has no production participation owner") |
| return nil |
| } |
| - in, err := productionRelocationRuntimeInputs(os.Getenv, release.OptDir()) |
| - if err != nil { |
| - log.Printf("agent: region HA: relocation pass not started: %v", err) |
| - return nil |
| - } |
| - runtime, err := NewRelocationRuntime(agentCtx, claimEntryEnv(claimCtx), entry.Store, local, claims.Providers, reg, svc, in) |
| - if err != nil || runtime == nil { |
| + var runtime *RelocationRuntime |
| + var err error |
| + if regionHAEnabled(getenv) { |
| + var in relocationRuntimeInputs |
| + if in, err = productionRelocationRuntimeInputs(os.Getenv, release.OptDir()); err == nil { |
| + runtime, err = NewRelocationRuntime(agentCtx, getenv, entry.Store, local, claims.Providers, reg, svc, in) |
| + } |
| if err != nil { |
| - log.Printf("agent: region HA: relocation pass not started: %v", err) |
| + log.Printf("agent: region HA: relocation pass not started, finishing completions only: %v", err) |
| + } |
| + } |
| + if runtime == nil { |
| + // Ruling 82: with claim mode on, a relocation that completed here |
| + // is finished whatever CARLOS_REGION_HA says. |
| + if runtime, err = newCompletionRuntime(local, reg, svc); err != nil { |
| + log.Printf("agent: relocation completion pass not started: %v", err) |
| + return nil |
| } |
| - return nil |
| } |
| go runtime.Run(agentCtx, entry.Store, nil) |
| return runtime |
| @@ -14492,16 +14679,20 @@ func (g CommittedLandingRecords) CheckLandingCommitted(ctx context.Context, o ho |
| ```diff |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| -@@ -276,6 +276,17 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| +@@ -276,6 +276,21 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| log.Fatalf("agent: %v", err) |
| } |
| claimCtx = withClaimRuntimeResources(claimCtx, ClaimRuntimeResources{Mode: "agent", Registry: reg, Manager: cgm, DataDir: dd, OptDir: release.OptDir(), UnitsDir: "/etc/systemd/system"}) |
| -+ if relocatePassEnabled(claimEntryEnv(claimCtx)) { |
| -+ // Region HA (stage B Task 10): the relocation owner source is |
| -+ // registered before the claim's owners open, and the assembly is |
| -+ // opened here so the relocation pass binds to the same one. |
| -+ if err := registerRelocationSource(claimCtx, dd); err != nil { |
| -+ log.Fatalf("agent: region HA: %v", err) |
| ++ if hostwrite.Enabled(claimEntryEnv(claimCtx)) { |
| ++ // Region HA (stage B Task 10): with CARLOS_REGION_HA on, the |
| ++ // relocation owner source is registered before the claim's owners |
| ++ // open. In claim mode the assembly is opened here, as the claim |
| ++ // factory would open it, so the relocation pass, or with region HA |
| ++ // off the completion pass (ruling 82), binds to the same one. |
| ++ if regionHAEnabled(claimEntryEnv(claimCtx)) { |
| ++ if err := registerRelocationSource(claimCtx, dd); err != nil { |
| ++ log.Fatalf("agent: region HA: %v", err) |
| ++ } |
| + } |
| + if claimCtx, err = ensureProductionClaimAssembly(claimCtx); err != nil { |
| + log.Fatalf("agent: host claims: %v", err) |
| @@ -14510,12 +14701,12 @@ func (g CommittedLandingRecords) CheckLandingCommitted(ctx context.Context, o ho |
| claims, err := newClaimWiring(claimCtx, claimEntryEnv(claimCtx), buildClaims) |
| if err != nil { |
| log.Fatalf("agent: host claims: %v", err) |
| -@@ -590,6 +601,9 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| +@@ -590,6 +605,9 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| } |
| }() |
| + if relocation := startRelocationPass(agentCtx, claimCtx, claims, reg, svc); relocation != nil { |
| -+ log.Printf("agent: region HA relocation pass started") |
| ++ log.Printf("agent: relocation pass started (completions only: %v)", relocation.Worker == nil) |
| + } |
| go adoptLoop(reg, svc, sd, claims.Writes) |
| go fastConvergeLoop(reg, svc, sd, claims.Writes, serialClaims, claims.Providers.Provision) |
| @@ -14546,7 +14737,7 @@ Run: |
| ```bash |
| nice -n 19 go test ./internal/hostrelocate/ ./internal/edgesfile/ -count=1 |
| nice -n 19 go test ./internal/console/ -run 'Edges|Eligib|Placement|Region|Candidate|BoxSelect|Executor|LiveEdges|CreateDoor|ClaimRuntime' -count=1 |
| -TMPDIR=$(mktemp -d /tmp/k.XXXX) nice -n 19 go test ./internal/host/ -count=1 -run '^(TestRelocat|TestRoot|TestCommittedLanding|TestDischargeIsUnqualified|TestPrepared|TestNoRelocation|TestRelease|TestEnsureLocal|TestVerifyReceipt|TestCensus|TestClaimCensus|TestPlan9|TestPlan11|TestClaimLaunch|TestProductionClaimLaunch|TestMovesProbeOwners|TestClaimHandover|TestCold)' |
| +TMPDIR=$(mktemp -d /tmp/k.XXXX) nice -n 19 go test ./internal/host/ -count=1 -run '^(TestRelocat|TestRoot|TestCompletionPass|TestCommittedLanding|TestDischargeIsUnqualified|TestPrepared|TestNoRelocation|TestRelease|TestEnsureLocal|TestVerifyReceipt|TestCensus|TestClaimCensus|TestPlan9|TestPlan11|TestClaimLaunch|TestProductionClaimLaunch|TestMovesProbeOwners|TestClaimHandover|TestCold)' |
| ``` |
| Expected: PASS. Run outside the sandbox (the `TestPlan9`/`TestPlan11` regressions listen on unix sockets), with `TMPDIR=$(mktemp -d /tmp/k.XXXX)`, removed afterwards. |
| @@ -14582,6 +14773,10 @@ Before `CARLOS_REGION_HA` is set on any box with a second box in its region, on |
| 16. **C11.** Until it lands each target gets one launchable probe attempt; set `LaterProbeAttempts` only after it does. |
| 17. **O2′ on dev.** A relocation of an app on its own `instance.key` pins with no `InstanceKey`. After O6, a relocation of a platform-key app pins `InstanceKey`, and changing a configured key between pin and install makes the install refuse (`VerifyDelivered`). |
| 18. **Task 10b (gate; R5-3).** Task 10b has landed and its tests pass: `Reconciler.FenceClosed` and `HoldRow` are wired, the source's row goes held at its fence's close with a status the edge does not serve, and the completion or the source's park-local ends the hold. Relocation is not switched on anywhere before this. |
| +19. **A completion its box cannot finish (ruling 82; runbook).** With claim mode on, every box runs the completion pass, whatever `CARLOS_REGION_HA` says. The agent logs "relocation pass started (completions only: true)" with region HA off. A host whose serving op shows `completed_relocation` without `discharged` refuses every start (moves, deletes, purges, placements, relocations) with "relocation <op> is not yet discharged on <box>" until that box's pass marks it. Usually the next pass does. If the box can never run its pass (it will not boot, or its discharge is refused for good), use the existing loss path; there is no override: |
| + - **Terminate the box.** The termination worker writes its fence. A failover relocation from the box then cites that fence, which the barrier exempts (ruling 81), and puts the box on `lost`. After that every start passes. |
| + - **Or have the box declared lost** through the claim's loss declaration (`hostclaim.DeclareLost`), which puts it on `lost`. The console's `declare-lost` action refuses while claim mode is on (apihostclaim.go), so in claim mode this path waits for the claim's scoped writer. |
| + - Never edit the claim by hand to clear the record. That reopens round 7's conflict: the box's participation slot is still the old op's, and the successor's `Prepare` answers `ErrLost`. |
| --- |
| @@ -14635,7 +14830,7 @@ Unchanged from the 1b-next plan's Task 11 (L13558-13586), with the model at `ori |
| - **The two-box end-to-end run** (R5-4) needs a two-box harness and an activator test service; until one exists it is checklist item 12 and Task 11's joint round. |
| - **W1, W2 and W4** are observable only on a box. |
| -## Unresolved in rev 5.2 |
| +## Unresolved in rev 5.3 |
| - **R5-2.** Rev 3's ported lifecycle property (ruling 63) stays withdrawn. The successor case Astra named is now prevented (ruling 77) and tested through the real start transitions; other interleavings the property explored are covered only by the per-hop tests. |
| - **R5-3.** Accepted while dormant; Task 10b is written and landed before activation (ruling 79). |
| @@ -14643,6 +14838,7 @@ Unchanged from the 1b-next plan's Task 11 (L13558-13586), with the model at `ori |
| - **M-K25** is a new moves ask; N11 is its committed fallback. |
| - **C10** is committed as region HA code; the claim session's confirmation is outstanding. |
| - **C12** (N13, `StartGuard`'s completion barrier) is committed in Task 8a; the claim session's confirmation is outstanding. |
| +- **The loss path in claim mode** (ruling 82, checklist item 19): today it is a termination followed by a failover start, and that start needs a starter (`Worker.Start`'s operator door or spec 3's placer), which this plan does not build. The console's `declare-lost` is refused in claim mode until the claim supplies its scoped writer. |
| ## Self-review |