Extend the pending-assignment ledger to reassignments (GH-3852) - #3853
Conversation
The GH-3698 ledger closed the re-decision hole for first-time assignments and silently excluded every reassignment. An agent being moved is still listed in its SOURCE node's persisted ActiveAgents, so Agent.OriginalNode is set, and applyPendingAssignments skipped it outright: if (agent.OriginalNode != null) continue; The ledger armed on a ReassignAgent but could never apply one, and TryBuildAssignmentCommand's OriginalNode != null branch consulted no ledger at all. So the leader re-decided the same move from scratch on every evaluation until the source's assignment row finally disappeared. Against the reported cluster shape -- 512 databases x 17 agents across five nodes, group affinity, three incumbents and two newcomers ramping in -- that is 3,468 reassignment decisions per cycle against a frozen snapshot, repeating indefinitely. Roughly 13 cycles of that is the reported ~45,000 in six minutes. Each decision writes an AssignmentChanged row (the observer runs before batchCommands and before the dispatcher, so nothing downstream dedupes it), feeding the node-record volume problem of #3658 at full rate. The outcome converged because ReassignAgents carries set-based value equality, so the dispatcher collapses an identical re-emitted batch while its lane is busy. That hold ends the moment the batch completes, though, so a re-decision landing before the snapshot catches up re-runs a real StopAgents round trip against a source that has already let go. Three changes: - applyPendingAssignments ends the wait only on a MATCHING node, so a move in flight sets PendingNode and holds its placement. - TryBuildAssignmentCommand returns false for a move already dispatched and still live. PendingRetryDue re-drives one that is never confirmed. - AgentCommandDispatcher tracks reassignments in a _moving map, keyed to the node the agents are moving TO (not the command's lane, which is the source). Kept separate from _inFlight because Enqueue must never suppress a reassignment -- it carries a stop. Without this the hold falls back to the ledger TTL (60s), which a batch's own reply window trivially outlives. Regression coverage in pending_reassignment_ledger; 4 of the 8 fail without the fix, the rest guard against over-suppression -- a pause still stops an agent mid-move, confirmation still clears the ledger, an unconfirmed move is still re-driven. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WHAuhdWS3XeAk16swV9G8m
StartAgents.StartBatchAsync got bounded parallelism in GH-3604/D3 for exactly this reason -- "daemon-agent starts are I/O bound, so a 50-agent chunk started one-at-a-time was seconds of dead wall-clock that blew the reply window". The stop side is the same shape and never got it: StopAgents ran a plain serial foreach. At AgentStartBatchSize = 50 against a source whose shards are slow to let go, that is the whole chunk's stop cost in series before ReassignAgents can cascade a single start, and every agent in the chunk is down for the duration. This was survivable only because the leader re-decided the same move on every evaluation cycle, trickling agents onto the destination through individual ReassignAgent commands alongside the batch. Removing that churn in the previous commit is what exposed the serial stop as the real cost -- SlowTests' agent_reassignment_at_scale caught it, and it is the reason that test is worth running by hand (it is in no CI workflow). agent_reassignment_at_scale, three runs each: main ledger fix + parallel stops fresh nodes >=20 each 38.0s 74.1s 14.0s converged at 176.3s 176.3s 31.1s worst backslide 8 72 40 Convergence is 5.7x faster than main, not merely restored. The backslide is deeper than main's 8 but well inside the test's <= 70 bar ("never worse than one reassignment chunk in flight") and it now lasts seconds rather than the ~100s a serial chunk took, so far fewer agent-seconds are spent down. StopLocallyAsync is per-agent independent -- a ConcurrentDictionary lookup and removal, the agent's own StopAsync, and a persistence delete -- so the fan-out is safe for distinct URIs, same as the start side. The per-agent try/catch is preserved so one failing stop still costs only itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WHAuhdWS3XeAk16swV9G8m
SlowTests follow-up — found and fixed a real regression, then a pre-existing one
The timelines are identical for the first 15 seconds, then split hard: Main's node 3 climbs while the total stays flat at 471 — stops and starts interleave. The branch's node 3 sits at 0 while the total declines 2/sec: the stops land and the starts never do, then arrive in big step jumps (90 → 110 → 120 → 140 → 160) at the very end. Cause
So at That was survivable only because of the churn this PR removes. The leader re-deciding the same move every cycle was trickling agents onto the destination through individual Result
Convergence is 5.7x faster than main, not merely restored. The backslide is deeper than main's 8 but well inside the test's
Verification
|
Closes #3852.
The gap
The GH-3698 pending-assignment ledger closed the re-decision hole for first-time assignments and silently excluded every reassignment. An agent being moved is still listed in its source node's persisted
ActiveAgents, soAgent.OriginalNodeis set, andapplyPendingAssignmentsskipped it outright:The ledger armed on a
ReassignAgentbut could never apply one, andTryBuildAssignmentCommand'sOriginalNode != nullbranch consulted no ledger at all — it went straight from "assigned != original" to a freshReassignAgent. So the leader re-decided the same move from scratch on every evaluation until the source's assignment row finally disappeared.Scale
Against the reported cluster shape — 512 databases x 17 agents across five nodes, group affinity, three incumbents holding everything and two newcomers ramping in — with the persisted snapshot frozen:
Roughly 13 cycles of that is the reported ~45,000 in six minutes. Each decision writes an
AssignmentChangedrow —_observer.AssignmentsChangedruns beforebatchCommandsand before the dispatcher, so nothing downstream dedupes it — feeding the node-record volume problem of #3658 at full rate.The outcome converged because
ReassignAgentscarries hand-written set-based value equality, so the dispatcher collapses an identical re-emitted batch while its lane is busy. That hold ends the moment the batch completes, though, so a re-decision landing before the snapshot catches up re-runs a realStopAgentsround trip against a source that has already let go.On the version framing in the issue: 6.24.4 already carried the GH-3698 ledger (landed 7/29, ahead of the 8/1 bump), so the original measurement was taken with the intended mitigation in place. #610 shortens the staleness window and reduces the absolute count on 6.24.6, but cannot close the gap — any slow ramp re-opens it at the same per-cycle rate.
Changes
applyPendingAssignmentsends the wait only on a matching node, so a move in flight setsPendingNodeand holds its placement (before the family distributes, so the distribution balances around it rather than re-splitting and yanking it back).TryBuildAssignmentCommandreturns false for a move already dispatched and still live.PendingRetryDuere-drives one that is never confirmed.AgentCommandDispatchertracks reassignments in a_movingmap, keyed to the node the agents are moving to — not the command's lane, which is the source. Kept deliberately separate from_inFlightbecauseEnqueuemust never suppress a reassignment: it carries a stop, and dropping that is the two-copies bugStartedAgentsOfguards against. Without this map the hold falls back to the ledger TTL (2 xCheckAssignmentPeriod, 60s), which a batch's own reply window trivially outlives.Coverage
New
pending_reassignment_ledger. 4 of the 8 fail without the fix; the rest guard against over-suppression — a pause still stops an agent mid-move, confirmation still clears the ledger, an unconfirmed move is still re-driven, and the dispatcher still collapses an identical in-flight batch.Full
CoreTestsgreen (2272 passed),wolverine.slnxRelease build clean.🤖 Generated with Claude Code
https://claude.ai/code/session_01WHAuhdWS3XeAk16swV9G8m