Skip to content

Fix #2260: an automatic re-drive must never un-park a NodeType - #2262

Merged
rbuergi merged 2 commits into
mainfrom
fix/2260-unpark-stale-snapshot
Aug 25, 2026
Merged

rbuergi merged 2 commits into
mainfrom
fix/2260-unpark-stale-snapshot

Conversation

@rbuergi

@rbuergi rbuergi commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Fixes #2260. Unblocks #2238, whose required check is red on RequirePrebuiltParkTest — the failure is this defect on main, not that diff.

The test was right: a parked type really was being un-parked

Measured with the registry instrumented (instance id on every entry, plus a stack trace on every entry actually removed, so nothing is inferred):

ISPARKED  => False          the compile watcher checks, not parked yet
PARK-ADDED   sources=null   the adopt-only gate refuses -> parks
ISPARKED  => False   <----- the TEST reads HERE, inside the window
UNPARK-REMOVED              the failed-verdict re-drive un-parks  (the only removal in the run)
PARK-ADDED   sources=0      the gate refuses again -> re-parks

A passing run has the same first five lines and a sixth, ISPARKED => True. So the assertion is not
racy in the "test is wrong" sense — it reads a moment in which the type genuinely is not parked.

Root cause

The failed-verdict re-drive (#1793) called parkRegistry.Unpark(hubPath) before its Update, so its
CompilationStatus = Pending flip would not be swallowed by the compile watcher's parked
short-circuit. Two defects:

  1. The un-park was decided from a SNAPSHOT and never re-validated. The Update lambda re-checks
    current state and can decline to flip — and the park is already gone by then, with nothing to
    re-park it. The type ends up neither parked nor re-driven: the one state the park exists to make
    impossible. The file's own comment ("correctness is the re-check inside the Update lambda, not
    this"
    ) held for the Pending flip and not for the un-park.
  2. Un-parking at all. Even a perfectly validated un-park leaves the type un-parked for the whole
    Pending -> refusal -> re-park round-trip, and any trigger arriving in that window is admitted.
    Under Modules:RequirePrebuilt that window opens on every refusal.

Fixing only (1) would not have closed the window — this was verified against the measured trace
before choosing the shape.

The fix: the re-drive asks to be let through, it does not clear the failure

The automatic re-drive never needed the terminal failure cleared — only its one flip let through.
Conflating those two is the defect.

  • AdmitOneRetry / TryConsumeRetryAdmission — a one-shot admission. The park entry is never
    touched, so IsParked is true at every instant, and every other trigger still meets the park.
  • The admission is claimed inside the Update lambda (ApplyFailedVerdictRedrive), so the
    decision and its effect commit against one observed state — every early return leaves the registry
    untouched.
  • Lifting a park stays reserved for the two events that genuinely END the failure: a human-requested
    build (Unpark, unchanged) and a successful compile (OnCompileSucceeded).
  • ParkAndNotify now refreshes the standing record when an admitted retry re-fails
    (compare-and-replace, never a blind write) instead of returning blind: ShouldRetryForSourceChange
    reads that source snapshot, and a stale one would keep re-arming the source-change retry. It still
    never re-notifies — one notification per broken type.

No retry, no delay, no watchdog, no widened bound, and RequirePrebuiltParkTest is untouched.

Verification

RequirePrebuiltParkTest, same amplifier both arms (--logger "console;verbosity=detailed",
DOTNET_PROCESSOR_COUNT=4), same machine:

arm result
origin/main, plain amplifier 12 / 15 pass
origin/main, + registry instrumentation (more I/O widens the window) 2 / 10 pass
this branch 15 / 15 pass, and 20 / 20 on the final tree

Post-fix the log shows exactly one PARKED after compile failure and one notification (it was
two of each while the park was being dropped and re-added), and the second refusal still reaches the
gate — i.e. the admission let the flip through without the park moving.

Regression suites, all green: NodeTypeCompileParkTest (9), NeverCompiledFailureRedriveTest (11),
RedriveObservationReplayTest, CodeEditRecompileTest, OverlaySelfHealInstanceRecycleTest,
RequirePrebuilt*, MeshWeaver.Graph.Test/FailedVerdictRedriveTest.

dotnet build -c Release -warnaserror clean (0 warnings, 0 errors) for MeshWeaver.Graph
(--no-incremental) and MeshWeaver.Hosting.Monolith.Test.

New tests

RequirePrebuiltParkTest catches this only by consequence. Three new pins in
NodeTypeCompileParkTest assert the invariant itself, on the re-drive's commit step, with a real
registry and no timing at all:

  • a park is never removed by a re-drive that did not observe it (the durable assertion);
  • a genuinely stale verdict still earns its one attempt — without un-parking, and the admission is
    one-shot;
  • an in-flight compile is never clobbered, and leaves no admission behind.

Follow-up (not in this PR)

SettleAsError never stamps FailedBuildInputs, so the adopt-only gate's refusal reads as "never
attempted" and burns one automatic re-drive per refused type. That is the trigger that made this
race reachable, but it is not the race, and removing it would not have closed the window (the first
refusal settles before CurrentSourceVersions is established, so the token moves anyway). Worth its
own issue — see the report.

🤖 Generated with Claude Code

RequirePrebuiltParkTest failed intermittently on CI because a type that
had parked really was ending up un-parked — the assertion was right and
the production code was wrong.

Mechanism, measured (registry instrumented with the instance id, plus a
stack trace on every entry actually removed):

  PARK-ADDED   sources=null   <- the adopt-only gate refuses, parks
  ISPARKED  => False          <- the TEST reads here, in the window
  UNPARK-REMOVED              <- the failed-verdict re-drive un-parks
  PARK-ADDED   sources=0      <- the gate refuses again, re-parks

The failed-verdict re-drive (#1793) called parkRegistry.Unpark(hubPath)
before its Update so its CompilationStatus=Pending flip would not be
swallowed by the compile watcher's parked short-circuit. That has two
defects:

1. The un-park was decided from a SNAPSHOT and never re-validated. The
   Update lambda re-checks current state and can decline to flip — and by
   then the park is already gone, with nothing to re-park it. The type is
   left neither parked nor re-driven: the one state the park exists to
   make impossible.

2. Even a perfectly validated un-park leaves the type un-parked for the
   whole Pending -> refusal -> re-park round-trip, and any trigger arriving
   in that window is admitted. Under Modules:RequirePrebuilt that window
   opens on EVERY refusal, because the gate's settle records no verdict
   inputs and so always reads as "never attempted".

The re-drive never needed the failure CLEARED — only its one flip let
through. It now asks for a one-shot admission (AdmitOneRetry /
TryConsumeRetryAdmission) and the park entry is never touched, so
IsParked is true at every instant. Lifting a park stays reserved for the
two events that genuinely end the failure: a human-requested build and a
successful compile.

The admission is claimed INSIDE the Update lambda (ApplyFailedVerdictRedrive),
so the decision and its effect commit against one observed state — every
early return leaves the registry untouched.

ParkAndNotify now refreshes the standing record (compare-and-replace, never
a blind write) when an admitted retry re-fails, instead of returning blind:
ShouldRetryForSourceChange reads that source snapshot, and a stale one would
keep re-arming the source-change retry. It still never re-notifies.

Verified on this machine, same amplifier (--logger "console;verbosity=detailed",
DOTNET_PROCESSOR_COUNT=4), before/after arms identical:

  before  12/15 pass   (2/10 with the diagnostic instrumentation amplifying it)
  after   15/15 pass

Three new pins in NodeTypeCompileParkTest assert the invariant directly
rather than by consequence: a park is never removed by a re-drive that did
not observe it; a genuinely stale verdict still earns its one attempt
without un-parking; an in-flight compile is never clobbered.

Fixes #2260

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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

Fixes the NodeType compile “park” invariant regression where the failed-verdict automatic re-drive could temporarily (or permanently) un-park a type, allowing stray triggers to slip through and undermining storm containment.

Changes:

  • Introduces a one-shot “retry admission” in NodeTypeCompileParkRegistry to let exactly one automatic re-drive Pending flip through the parked short-circuit without removing the park.
  • Refactors the failed-verdict re-drive to commit the flip (and admission) inside the Update lambda (ApplyFailedVerdictRedrive) rather than un-parking ahead of time.
  • Adds targeted tests pinning the invariant directly, and adds a What’s New entry documenting the behavioral change.

Reviewed changes

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

File Description
test/MeshWeaver.Hosting.Monolith.Test/NodeTypeCompileParkTest.cs Adds three focused tests asserting that automatic re-drive never lifts/removes a park and that admissions are one-shot.
src/MeshWeaver.Graph/Configuration/NodeTypeCompileParkRegistry.cs Adds the retry-admission mechanism and refreshes the parked record on re-fail without re-notifying.
src/MeshWeaver.Graph/Configuration/NodeTypeCompilationHelpers.cs Updates the compile watcher to honor one-shot admissions and moves failed-verdict redrive into a single commit-step helper.
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-25-parked-type-stays-parked.md Documents the behavior change in the What’s New feed.

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

Comment thread src/MeshWeaver.Graph/Configuration/NodeTypeCompilationHelpers.cs Outdated
Review catch (Copilot, PR #2262). ApplyFailedVerdictRedrive granted the
one-shot admission unconditionally whenever it flipped the node back to
Pending. An admission for an UN-parked type is never consumed — the parked
short-circuit it exists to pass is not taken — so it sat in the registry
until some LATER park, where a stray Pending flip could spend it and get
through the very containment this PR restores.

That case is the common one, not a corner: a failure predating this PROCESS
is not in the in-memory registry at all, so the re-drive routinely runs
against an un-parked type.

Gating the grant on IsParked is deliberately stronger than pruning stale
admissions afterwards, because it makes the leak impossible rather than
unlikely. It establishes the invariant "an admission implies a standing
park" — and _parked entries are removed in exactly two places, Unpark and
OnCompileSucceeded, both of which already clear _retryAdmissions. So an
admission can never outlive the park it was granted against, and can never
be waiting when a later park arrives.

Trade-off, stated: if a park lands between this check and the watcher's
read, the flip meets the short-circuit and is answered with the cached
error instead of being dispatched — one automatic retry lost, in a window
where the type has just re-failed and the newest verdict is the one being
served. That costs a retry, never containment.

Pinned by RedriveCommit_OnATypeThatIsNotParked_FlipsAndLeavesNoAdmissionBehind,
which uses the exact shape the review named.

Verified on the amended tree: NodeTypeCompileParkTest 10/10,
NeverCompiledFailureRedriveTest 11/11, FailedVerdictRedriveTest 20/20,
RedriveObservationReplay / CodeEditRecompile / OverlaySelfHealInstanceRecycle
green, and RequirePrebuiltParkTest 20/20 under the detailed-logger amplifier.
Release -warnaserror clean for MeshWeaver.Graph (--no-incremental) and
MeshWeaver.Hosting.Monolith.Test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 3)

    9 files  ±0      9 suites  ±0   5m 3s ⏱️ -24s
1 693 tests ±0  1 686 ✅ ±0  7 💤 ±0  0 ❌ ±0 
2 218 runs  +3  2 211 ✅ +3  7 💤 ±0  0 ❌ ±0 

Results for commit bce3359. ± Comparison against base commit 8906cad.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 0)

1 313 tests  ±0   1 313 ✅ ±0   12m 59s ⏱️ - 3m 4s
    9 suites ±0       0 💤 ±0 
    9 files   ±0       0 ❌ ±0 

Results for commit bce3359. ± Comparison against base commit 8906cad.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 5)

   10 files  ±0     10 suites  ±0   7m 21s ⏱️ + 1m 28s
1 298 tests +9  1 297 ✅ +10  1 💤 ±0  0 ❌  - 1 
1 299 runs  +9  1 298 ✅ +10  1 💤 ±0  0 ❌  - 1 

Results for commit bce3359. ± Comparison against base commit 8906cad.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 2)

3 280 tests   - 5   3 280 ✅  - 5   9m 42s ⏱️ +59s
    8 suites ±0       0 💤 ±0 
    8 files   ±0       0 ❌ ±0 

Results for commit bce3359. ± Comparison against base commit 8906cad.

This pull request removes 107 and adds 102 tests. Note that renamed tests count towards both.
MeshWeaver.Hosting.Monolith.Test.PostDeleteReadVerdictTest ‑ ReadRacingDeletedNodeTeardown_ReachesDefinitiveNotFound
MeshWeaver.Hosting.Monolith.Test.RecursiveDeletePermissionTest ‑ RecursiveDelete_DeniedOnDescendant_RefusedAtomically_NothingDeleted
MeshWeaver.Hosting.Monolith.Test.RecursiveDeletePermissionTest ‑ RecursiveDelete_PlanContainsCallersOwnAccessGrant_CompletesFully
MeshWeaver.Hosting.Monolith.Test.RedriveObservationReplayTest ‑ TheNewNegativeAssertion_AcceptsAQuietWindow
MeshWeaver.Hosting.Monolith.Test.RedriveObservationReplayTest ‑ TheNewNegativeAssertion_CatchesADriveThatOnlyTheMirrorSaw
MeshWeaver.Hosting.Monolith.Test.RedriveObservationReplayTest ‑ TheOldForgeBarrier_IsSatisfiedByTheReplayedPreForgeSnapshot
MeshWeaver.Hosting.Monolith.Test.RedriveObservationReplayTest ‑ TheOldNegativeAssertion_IsSatisfiedByAReplayThatHidesAWholeReDrive
MeshWeaver.Hosting.Monolith.Test.RedriveObservationReplayTest ‑ TheWatermarkRejectsOnlyTheReplay
MeshWeaver.Hosting.Monolith.Test.ReleaseArtifactLinkTest ‑ CompiledRelease_CarriesAnArtifactResolvableInTheProducingLane
MeshWeaver.Hosting.Monolith.Test.ReleaseRequestWatcherHighWaterTest ‑ BurstOfTwoReleaseTriggers_SecondTriggerIsNotLost
…
Memex.Portal.Shared.Test.EaConsentControllerActivationTest ‑ EaGraphAuth_IsRegistered_EvenWithMailDisabled
Memex.Portal.Shared.Test.EaConsentControllerActivationTest ‑ NoControllerDependency_DependsOnADeploymentFlag
Memex.Portal.Shared.Test.EaConsentControllerActivationTest ‑ TheSweep_CoversTheConsentController
MeshWeaver.Hosting.Monolith.Test.PostCreationNodeIsAnnouncedTest ‑ ANodeWrittenByAPostCreationHandler_IsAnnouncedOnTheMeshChangeFeed
MeshWeaver.Hosting.Monolith.Test.PostCreationNodeIsAnnouncedTest ‑ APathProbedBeforeItExisted_IsReachableRightAfterTheHandlerWritesIt
MeshWeaver.Hosting.Monolith.Test.RecursiveDeleteAuthoritativeTest ‑ RecursiveDelete_DescendantsBehindNodelessSegments_AreDeleted_AndStorageVerifiedEmpty
MeshWeaver.Hosting.Monolith.Test.RecursiveDeleteAuthoritativeTest ‑ RecursiveDelete_NodeCreatedMidFlightBypassingGuard_IsDrainedBeforeSuccess
MeshWeaver.Hosting.Monolith.Test.RecursiveDeleteAuthoritativeTest ‑ Write_UnderSubtreeBeingDeleted_IsRefused_AndNodeDoesNotSurvive
MeshWeaver.Hosting.Monolith.Test.RecycleSurvivesItsOwnDisposeTest ‑ TheHubThatConfirmsTheRecycle_OutlivesTheHubItRecycles
MeshWeaver.Hosting.Monolith.Test.RefusedWriteOwnerRebaseTest ‑ LiveOwnerBehindTheDurableRow_RebasesOnRefusal_AndSubsequentWritesLand
…

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 4)

2 178 tests  +6   1 881 ✅ +6   10m 7s ⏱️ -7s
    9 suites ±0     297 💤 ±0 
    9 files   ±0       0 ❌ ±0 

Results for commit bce3359. ± Comparison against base commit 8906cad.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results (shard 1)

1 881 tests  +14   1 881 ✅ +14   7m 47s ⏱️ -38s
   10 suites ± 0       0 💤 ± 0 
   10 files   ± 0       0 ❌ ± 0 

Results for commit bce3359. ± Comparison against base commit 8906cad.

This pull request removes 97 and adds 111 tests. Note that renamed tests count towards both.
MeshWeaver.Hosting.Monolith.Test.RecursiveDeleteAuthoritativeTest ‑ RecursiveDelete_DescendantsBehindNodelessSegments_AreDeleted_AndStorageVerifiedEmpty
MeshWeaver.Hosting.Monolith.Test.RecursiveDeleteAuthoritativeTest ‑ RecursiveDelete_NodeCreatedMidFlightBypassingGuard_IsDrainedBeforeSuccess
MeshWeaver.Hosting.Monolith.Test.RecursiveDeleteAuthoritativeTest ‑ Write_UnderSubtreeBeingDeleted_IsRefused_AndNodeDoesNotSurvive
MeshWeaver.Hosting.Monolith.Test.RecycleSurvivesItsOwnDisposeTest ‑ TheHubThatConfirmsTheRecycle_OutlivesTheHubItRecycles
MeshWeaver.Hosting.Monolith.Test.RefusedWriteOwnerRebaseTest ‑ LiveOwnerBehindTheDurableRow_RebasesOnRefusal_AndSubsequentWritesLand
MeshWeaver.Hosting.Monolith.Test.ReleaseRequestSatisfiedByCurrentBuildTest ‑ ReleaseRequest_OnACurrentBuild_IsSatisfiedWithoutCompiling_UnlessForced
MeshWeaver.Hosting.Monolith.Test.RequirePrebuiltParkTest ‑ AValidTypeWithNoAdoptedAssembly_ParksWithTheNamedRefusal_AndNeverCompiles
MeshWeaver.Hosting.Monolith.Test.RouterTrafficTest ‑ NodeCreate_IsNeverIssuedByTheRouter
MeshWeaver.Hosting.Monolith.Test.RouterTrafficTest ‑ SignalRTokenValidation_IsNeverIssuedByTheRouter
MeshWeaver.Hosting.Monolith.Test.ScheduledPostWatcherTest ‑ APostWithNoAuthorProfile_IsNotArmed
…
MeshWeaver.Hosting.Monolith.Test.NodeTypeCompileParkTest ‑ RedriveCommit_AgainstAGenuinelyStaleVerdict_AdmitsOneRetry_WithoutUnparking
MeshWeaver.Hosting.Monolith.Test.NodeTypeCompileParkTest ‑ RedriveCommit_AgainstAVerdictFormedUnderTheLiveInputs_LeavesTheParkUntouched
MeshWeaver.Hosting.Monolith.Test.NodeTypeCompileParkTest ‑ RedriveCommit_OnATypeThatIsNotParked_FlipsAndLeavesNoAdmissionBehind
MeshWeaver.Hosting.Monolith.Test.NodeTypeCompileParkTest ‑ RedriveCommit_WhileACompileIsInFlight_LeavesTheParkAndTheNodeUntouched
MeshWeaver.Hosting.Monolith.Test.PostDeleteReadVerdictTest ‑ ReadRacingDeletedNodeTeardown_ReachesDefinitiveNotFound
MeshWeaver.Hosting.Monolith.Test.RecursiveDeletePermissionTest ‑ RecursiveDelete_DeniedOnDescendant_RefusedAtomically_NothingDeleted
MeshWeaver.Hosting.Monolith.Test.RecursiveDeletePermissionTest ‑ RecursiveDelete_PlanContainsCallersOwnAccessGrant_CompletesFully
MeshWeaver.Hosting.Monolith.Test.RedriveObservationReplayTest ‑ TheNewNegativeAssertion_AcceptsAQuietWindow
MeshWeaver.Hosting.Monolith.Test.RedriveObservationReplayTest ‑ TheNewNegativeAssertion_CatchesADriveThatOnlyTheMirrorSaw
MeshWeaver.Hosting.Monolith.Test.RedriveObservationReplayTest ‑ TheOldForgeBarrier_IsSatisfiedByTheReplayedPreForgeSnapshot
…

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

    55 files  ± 0      55 suites  ±0   53m 2s ⏱️ - 1m 46s
11 643 tests +24  11 338 ✅ +25  305 💤 ±0  0 ❌  - 1 
12 169 runs  +27  11 864 ✅ +28  305 💤 ±0  0 ❌  - 1 

Results for commit bce3359. ± Comparison against base commit 8906cad.

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.

RequirePrebuiltParkTest flake is a real defect: the failed-verdict re-drive un-parks from a stale snapshot and drops a newer park

2 participants