Skip to content

fix(#1979): answer the restart-as-activation question per PROCESS, and never reassuringly - #2037

Merged
rbuergi merged 1 commit into
mainfrom
fix/1979-pending-activation-per-process
Aug 22, 2026
Merged

rbuergi merged 1 commit into
mainfrom
fix/1979-pending-activation-per-process

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Continues #1979 (#2003 shipped item 2, the operator surface).

What it was — and what it still is

PendingRestart was written by both landing sites, consumed at boot, and read by nothing. #2003 gave it a health check. Reading that flag answers the wrong question, and the shipped check had three holes, all of which fail quietly:

  1. It is one deployment-wide boolean that the next boot clears. On a multi-replica deployment the pod that clears it is not the pod that is missing the module. Replica A lands one and sets the flag; replica B restarts for unrelated reasons, applies the list, resets it; A keeps serving without the module while every surface reads "nothing pending".
  2. It named every Enabled entry, not the pending ones. One landing on a deployment carrying fifteen modules reported "15 module(s) are landed but not yet loaded".
  3. A corrupt sidecar read as Healthy. ModuleActivationSidecar.Read swallows an unparseable file into the empty list and reports it through an onCorrupt callback the check did not pass. So "cannot parse the activation state" rendered as "no pending activation" — cheerfully, forever. Exactly the trap this cluster keeps falling into.

Item 1 of the issue (the Store card) was also still open, and could not be built at all: "no API surface, so nothing external can [read it]".

What changed

ModuleActivationStatus (pure, MeshWeaver.PluginCatalog) — the honest question: which enabled entries are not among the assemblies this process loaded. True per pod, needs no extra state, names only what is genuinely missing, and immune to another replica's boot. A Disabled entry is never pending (it is the record of an uninstall; the module's absence is the outcome).

PendingModuleActivations — the one reader every surface consults. Its ModuleActivationReport separates a known empty answer from an undetermined one, and Describe() is shared so an operator and a buyer are never told different numbers.

The health check now asks that reader, and reports Degraded for both pending and undetermined — still never Unhealthy: the pod serves correctly with what it loaded, and failing readiness would stall a rollout over work the rollout itself performs.

Registered in DI beside ModuleLandingService, rooted at the same resolved module root, so a NodeType's layout area can resolve it from hub.ServiceProvider. IsPendingForPackage(packagePath) answers per package off the entries' PackagePath back-pointer — that is what the Store install step needs to say "restart required to finish activating this" instead of a bare "installed". A blank path matches nothing; it is not a wildcard.

Note the deliberate asymmetry: an undetermined state does not put a restart prompt on every package card. It is an operator signal, reported where it is actionable — telling a buyer to restart because a file would not parse is noise they cannot act on.

Tests

test/MeshWeaver.PluginCatalog.Test/ModuleActivationStatusTest.cs (13) and the health check's suite grown to 7.

Passed!  - Failed: 0, Passed: 13, Skipped: 0, Total: 13  (MeshWeaver.PluginCatalog.Test)
Passed!  - Failed: 0, Passed:  7, Skipped: 0, Total:  7  (Memex.Portal.Shared.Test)

Fail-closed proofs: ACorruptSidecar_IsUNDETERMINED_NotEmpty, ACorruptSidecar_IsDegraded_NeverHealthy, AnUndeterminedState_DoesNotPutARestartPromptOnEveryCard.

Non-vacuity — the health check reverted to origin/main's implementation, same tests:

Failed!  - Failed: 4, Passed: 3, Skipped: 0, Total: 7

  Failed ADisabledEntry_IsNeverPending                    Expected: Healthy
  Failed AModuleThisProcessHasLOADED_IsNotPending         Expected: Healthy
  Failed ACorruptSidecar_IsDegraded_NeverHealthy          Expected: Degraded
  Failed AClearedFlagWithAnUnloadedModule_IsStillDegraded Expected: Degraded

For the maintainer

The remaining half of item 1 — rendering it on the package card — is a change in MeshWeaver.Plugins (Store/Plugin/Source/PluginLayoutAreas.cs, the install CTA), not here. This PR is the platform seam it needs; it is deliberately a separate PR because the two repos deploy on different lanes.

PendingRestart itself is left exactly as it is — still written, still consumed at boot. Nothing now depends on it being accurate across replicas, which is the property it never had.

🤖 Generated with Claude Code

…d never reassuringly

#2003 gave PendingRestart an operator surface. Reading that flag, though, answers
the wrong question, and the shipped check had two holes that both fail quietly.

1. PendingRestart is ONE deployment-wide boolean that the next boot clears — and on
   a multi-replica deployment the pod that clears it is not the pod missing the
   module. Replica A lands a module and sets it; replica B restarts for unrelated
   reasons, applies the list and resets it; A keeps serving WITHOUT the module while
   every surface reads "nothing pending".

2. It named every ENABLED entry, not the pending ones — so one landing on a
   deployment carrying fifteen modules reported "15 module(s) are landed but not yet
   loaded".

3. `ModuleActivationSidecar.Read` swallows an unparseable file into the EMPTY list,
   and the check ignored its corruption callback. A corrupt sidecar therefore
   reported Healthy — "no pending activation" — cheerfully, forever.

`ModuleActivationStatus` (pure) derives the honest answer: the persisted enabled
entries MINUS what this process actually loaded. That is true per pod, needs no
extra state, and names only what is genuinely missing. `PendingModuleActivations`
is the one reader every surface consults, and its report distinguishes a KNOWN
empty answer from an UNDETERMINED one — a check that cannot reach its evidence must
say so, not default to the reassuring answer. The health check reports both
Degraded (still never Unhealthy: the pod serves correctly with what it loaded).

Registered in DI beside ModuleLandingService, rooted at the same resolved module
root, so a NodeType's layout area can resolve it from hub.ServiceProvider — issue
item 1, the Store install step, is the surface where the missing last step is
actually met, and it now has something to ask. `IsPendingForPackage` answers per
package off the entries' PackagePath back-pointer; a blank path is not a wildcard.

Tests: ModuleActivationStatusTest (13, pure + over the durable sidecar) and the
health check's suite grown to 7.

Non-vacuity: against origin/main's health check, 4 of the 7 fail —
AClearedFlagWithAnUnloadedModule_IsStillDegraded, AModuleThisProcessHasLOADED_IsNotPending,
ADisabledEntry_IsNeverPending and ACorruptSidecar_IsDegraded_NeverHealthy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 22, 2026 05:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the “restart-as-activation” signal for landed modules to be process-accurate (per pod) and fail-closed (corrupt/undetermined state is surfaced as such), enabling both operator health reporting and a reusable reader for UI surfaces (e.g., the Store) without relying on the deployment-wide PendingRestart flag.

Changes:

  • Introduces ModuleActivationStatus (pure derivation) and PendingModuleActivations (single reader) to compute which enabled modules are landed but not loaded in the current process.
  • Updates the portal health check to use the new reader and to report Degraded for both pending activations and undetermined/corrupt sidecar state.
  • Adds/extends unit tests to cover multi-replica semantics, corrupt sidecar handling, and per-package “restart required” matching.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
test/MeshWeaver.PluginCatalog.Test/ModuleActivationStatusTest.cs Adds a new test suite covering the pure derivation, per-package matching, and durable-sidecar reader behavior.
test/Memex.Portal.Shared.Test/PendingModuleActivationHealthCheckTest.cs Expands health check tests for multi-replica PendingRestart holes and corrupt-sidecar fail-closed behavior.
src/MeshWeaver.PluginCatalog/PluginCatalogConfigurationExtensions.cs Registers PendingModuleActivations in DI alongside ModuleLandingService using the same resolved module root.
src/MeshWeaver.PluginCatalog/PendingModuleActivations.cs Adds the shared reader (Read, IsPendingForPackage) and a report type that distinguishes “known empty” vs “undetermined”.
src/MeshWeaver.PluginCatalog/ModuleActivationStatus.cs Adds the pure per-process derivation, per-package matcher, loaded-assembly enumeration, and shared description formatting.
memex/aspire/Memex.Portal.Distributed/PendingModuleActivationHealthCheck.cs Switches the health check to the new reader and aligns status/reporting with the fail-closed semantics.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +1 to +2
using System.Collections.Immutable;
using System.Reflection;
@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

   10 files  ±0     10 suites  ±0   5m 13s ⏱️ -8s
1 731 tests ±0  1 731 ✅ ±0  0 💤 ±0  0 ❌ ±0 
2 207 runs  ±0  2 207 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit f3ff80f. ± Comparison against base commit de3f8b7.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

1 232 tests  ±0   1 228 ✅ ±0   11m 43s ⏱️ + 1m 33s
    9 suites ±0       4 💤 ±0 
    9 files   ±0       0 ❌ ±0 

Results for commit f3ff80f. ± Comparison against base commit de3f8b7.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

1 215 tests  ±0   1 214 ✅ ±0   6m 55s ⏱️ + 1m 47s
   11 suites ±0       1 💤 ±0 
   11 files   ±0       0 ❌ ±0 

Results for commit f3ff80f. ± Comparison against base commit de3f8b7.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

1 818 tests  +13   1 710 ✅ +13   9m 15s ⏱️ -14s
   10 suites ± 0     108 💤 ± 0 
   10 files   ± 0       0 ❌ ± 0 

Results for commit f3ff80f. ± Comparison against base commit de3f8b7.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

3 218 tests  +3   3 218 ✅ +3   8m 22s ⏱️ +15s
    9 suites ±0       0 💤 ±0 
    9 files   ±0       0 ❌ ±0 

Results for commit f3ff80f. ± Comparison against base commit de3f8b7.

This pull request removes 1 and adds 4 tests. Note that renamed tests count towards both.
Memex.Portal.Shared.Test.PendingModuleActivationHealthCheckTest ‑ NoPendingActivation_IsHealthy
Memex.Portal.Shared.Test.PendingModuleActivationHealthCheckTest ‑ AClearedFlagWithAnUnloadedModule_IsStillDegraded
Memex.Portal.Shared.Test.PendingModuleActivationHealthCheckTest ‑ ACorruptSidecar_IsDegraded_NeverHealthy
Memex.Portal.Shared.Test.PendingModuleActivationHealthCheckTest ‑ ADisabledEntry_IsNeverPending
Memex.Portal.Shared.Test.PendingModuleActivationHealthCheckTest ‑ AModuleThisProcessHasLOADED_IsNotPending

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 930 tests  ±0   1 737 ✅ ±0   8m 32s ⏱️ +3s
   10 suites ±0     193 💤 ±0 
   10 files   ±0       0 ❌ ±0 

Results for commit f3ff80f. ± Comparison against base commit de3f8b7.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

    59 files  ± 0      59 suites  ±0   50m 3s ⏱️ + 3m 16s
11 144 tests +16  10 838 ✅ +16  306 💤 ±0  0 ❌ ±0 
11 620 runs  +16  11 314 ✅ +16  306 💤 ±0  0 ❌ ±0 

Results for commit f3ff80f. ± Comparison against base commit de3f8b7.

This pull request removes 1 and adds 17 tests. Note that renamed tests count towards both.
Memex.Portal.Shared.Test.PendingModuleActivationHealthCheckTest ‑ NoPendingActivation_IsHealthy
Memex.Portal.Shared.Test.PendingModuleActivationHealthCheckTest ‑ AClearedFlagWithAnUnloadedModule_IsStillDegraded
Memex.Portal.Shared.Test.PendingModuleActivationHealthCheckTest ‑ ACorruptSidecar_IsDegraded_NeverHealthy
Memex.Portal.Shared.Test.PendingModuleActivationHealthCheckTest ‑ ADisabledEntry_IsNeverPending
Memex.Portal.Shared.Test.PendingModuleActivationHealthCheckTest ‑ AModuleThisProcessHasLOADED_IsNotPending
MeshWeaver.PluginCatalog.Test.ModuleActivationStatusTest ‑ ABlankPackagePath_MatchesNothing
MeshWeaver.PluginCatalog.Test.ModuleActivationStatusTest ‑ ACorruptSidecar_IsUNDETERMINED_NotEmpty
MeshWeaver.PluginCatalog.Test.ModuleActivationStatusTest ‑ ADisabledEntry_IsNeverPending
MeshWeaver.PluginCatalog.Test.ModuleActivationStatusTest ‑ APackagePathMatches_ItsOwnPendingModuleOnly
MeshWeaver.PluginCatalog.Test.ModuleActivationStatusTest ‑ AnAbsentSidecar_IsAKnownEmptyAnswer_NotAnUndeterminedOne
MeshWeaver.PluginCatalog.Test.ModuleActivationStatusTest ‑ AnEnabledEntryThatIsNotLoaded_IsPending
…

@rbuergi
rbuergi merged commit 70f7d22 into main Aug 22, 2026
24 checks passed
@rbuergi
rbuergi deleted the fix/1979-pending-activation-per-process branch August 28, 2026 08:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants