Stop shattering agent Uris that contain a comma (GH-3733) - #3736
Merged
Conversation
`AgentsStarted`, `StartAgents`, `AgentsStopped` and `StopAgents` all round-trip their `Uri[]` payload as a comma-joined string. A comma is a **legal** URI character -- RFC 3986 lists it as a sub-delim, permitted unescaped in a path segment -- and there is no escaping here, so a single agent URI containing one is split into fragments and `new Uri(fragment)` throws. Agent URIs embed operator- and tenant-supplied strings. An event-subscription URI is `event-subscriptions://marten/main/<host>.<db>/<projection>/all/v<n>/<tenant>`, so a tenant id with a comma in it reaches this code without anyone doing anything exotic. Because the read side built the whole array in one LINQ projection, the throw took out the **entire batch**, not just the offending entry. `AgentsStarted` is the reply that confirms an agent start, and `AssignAgents.ExecuteAsync` treats a null response as "no confirmation" and moves on -- so one comma-bearing agent voided the confirmation for every agent in its chunk. On GH-3698's cluster that is precisely the state that looked like a stall: the leader's own local starts need no reply and kept landing, while remote chunks went unconfirmed. Newline is the delimiter instead -- not legal unescaped in a URI, and `Uri.ToString()` never emits a bare one. The comma nonetheless stays the default on the wire. It is what every 6.24.0-and-earlier node both writes and reads, and a rolling upgrade has to keep working in both directions, so only a payload that actually contains a comma -- the case that was already broken on both sides -- switches to the newline form. It announces itself with a leading newline, and the two formats can never be confused because a legacy payload always begins with a URI scheme character. Entries are also parsed one at a time now, so an unreadable one names itself and the full payload instead of surfacing as a bare "Invalid URI: The format of the URI could not be determined." with no way to tell which agent, or which node, was responsible. Audited the sibling `ISerializable` agent commands: `StartAgent`, `StopAgent`, `CheckAgentHealth` and `NodeRecord` carry a single URI, an empty payload, or JSON, so none share the pattern. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merged
This was referenced Jul 30, 2026
Open
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.
Closes #3733.
The defect
AgentsStarted,StartAgents,AgentsStoppedandStopAgentsall round-trip theirUri[]payload as a comma-joined string. A comma is a legal URI character — RFC 3986 lists it as a sub-delim, permitted unescaped in a path segment — and there is no escaping here, so one agent URI containing a comma is split into fragments andnew Uri(fragment)throws.Agent URIs embed operator- and tenant-supplied strings. An event-subscription URI is
event-subscriptions://marten/main/<host>.<db>/<projection>/all/v<n>/<tenant>, so a tenant id with a comma in it reaches this code without anyone doing anything exotic.Because the read side built the whole array in one LINQ projection, the throw took out the entire batch.
AgentsStartedis the reply that confirms an agent start, andAssignAgents.ExecuteAsynctreats a null response as "no confirmation" and moves on — so one comma-bearing agent voided the confirmation for every agent in its chunk. On #3698's cluster that is exactly the state that looked like a stall: the leader's own local starts need no reply and kept landing, while remote chunks went unconfirmed.The fix
Newline is the delimiter — not legal unescaped in a URI, and
Uri.ToString()never emits a bare one.The comma stays the default on the wire. It is what every 6.24.0-and-earlier node both writes and reads, and a rolling upgrade has to keep working in both directions. Only a payload that actually contains a comma — the case that was already broken on both sides — switches to the newline form, and it announces itself with a leading newline. The two formats can never be confused, because a legacy payload always begins with a URI scheme character.
Without that, an upgraded leader sending
\n-joinedStartAgentsto a not-yet-upgraded follower would break agent starts that work today, which is a worse trade than the bug being fixed.Entries are also parsed one at a time now, so an unreadable one names itself and the full payload rather than surfacing as a bare
Invalid URI: The format of the URI could not be determined.with no way to tell which agent, or which node, was responsible.Sibling audit
As suggested on the issue, checked the other
ISerializableagent commands.StartAgentandStopAgentcarry a single URI,CheckAgentHealthan empty payload, andNodeRecordis JSON. None share the pattern.Tests
In
agent_command_serialization:[Theory]over three comma-bearing shapes (comma in a tenant id, comma-space, and the production case — two agents where only one has a comma, previously voiding both), asserted across all four commands;All green, full
wolverine.slnxRelease build clean.🤖 Generated with Claude Code