ApplyRestrictionsAsync never dispatched the commands it computed (GH-698)#3396
Merged
Conversation
) Pausing an agent through IAgentRuntime.ApplyRestrictionsAsync persisted the restriction row and then had NO immediate effect -- the agent kept running. EvaluateAssignmentsAsync *returns* the commands that carry a restriction out to the cluster: pausing an agent detaches it from the assignment grid, which produces a StopRemoteAgent. Every other caller pumps that return value through a MessageBus; ApplyRestrictionsAsync discarded it. So the pause only took hold whenever a later leader heartbeat happened to re-read restrictions from the database -- and a caller that acked "paused" on the strength of the method returning was acking a no-op. Second defect, same method: the in-memory WolverineRuntime.Restrictions was never refreshed. It is otherwise only loaded at startup, but ListeningAgent reads it on every start attempt to decide whether a listener is meant to stay paused -- so a listener paused through restrictions would keep restarting until the node did. ApplyRestrictionsAsync now drains the returned AgentCommands cascade and refreshes the in-memory copy. The drain loop is shared with the health-check loop rather than copied a third time. Found while investigating CritterWatch#698 (the console's "pause projection" acks Succeeded but the agent never pauses). This is the upstream half. Test: pausing_an_agent_stops_it_immediately in LeadershipElectionCompliance, so it runs for every provider. It disables health checks first on purpose -- the health-check loop re-reads restrictions from the database and would eventually stop the agent anyway, masking the defect; the bug is the absence of an IMMEDIATE effect. Verified red against the old code, green with the fix. Full Postgres leader-election suite 14/14. ApplyRestrictionsAsync had zero test coverage, which is what let this ship. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jul 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Upstream half of CritterWatch#698 (@erdtsieck — the console's "pause projection" acks Succeeded but the agent never pauses). Targeting 6.18.0.
The bug
IAgentRuntime.ApplyRestrictionsAsyncpersisted the restriction row and then had no immediate effect — the agent kept running.EvaluateAssignmentsAsyncreturns the commands that carry a restriction out to the cluster: pausing an agent detaches it from the assignment grid, which produces aStopRemoteAgent. Every other caller pumps that return value through aMessageBus— the health-check loop does exactly this.ApplyRestrictionsAsyncthrew it away:So the pause only took hold whenever some later leader heartbeat happened to re-read restrictions from the database. Any caller that treated the method returning as "it is paused now" — which is exactly what CritterWatch's pause handler does — was acking a no-op.
Second defect, same method
The in-memory
WolverineRuntime.Restrictionswas never refreshed. It is otherwise only loaded once at startup (WolverineRuntime.HostService.cs), butListeningAgentreads it on every start attempt to decide whether a listener is meant to stay paused. So a listener paused through restrictions would keep restarting itself until the node restarted.The fix
ApplyRestrictionsAsyncnow drains the returnedAgentCommandscascade (commands can yield further commands) and refreshes the in-memory copy. The drain loop is shared with the health-check loop rather than copied a third time.Test
pausing_an_agent_stops_it_immediately, added toLeadershipElectionComplianceso it runs for every provider.It disables health checks first, on purpose. The health-check loop re-reads restrictions from the database and would eventually stop the agent anyway — a polling assertion would therefore pass even against the broken code and prove nothing. The defect is the absence of an immediate effect, so the test asserts synchronously once
ApplyRestrictionsAsyncreturns.wolverine.slnxRelease build clean.ApplyRestrictionsAsynchad zero test coverage before this, which is what let it ship.Does this close CritterWatch#698?
It fixes a real defect that produces exactly the reported symptom, but not necessarily the reporter's
wolverine_agent_restrictions= 0 rows. The persist path is unconditional and agent-family-agnostic, so I cannot statically explain zero rows; the restriction should have been written even before this fix. The leading remaining hypothesis is that the command executed against a different Wolverine runtime than the one inspected (the console has its own compiledPauseProjectionHandler), which would put the row in the console's database. That is being followed up on the CritterWatch side, along with verify-before-ack so a green ack can never again mean nothing happened.🤖 Generated with Claude Code