Trace client agent cleanup failures and pin the group's method set - #2963
Open
jebrans wants to merge 3 commits into
Open
Trace client agent cleanup failures and pin the group's method set#2963jebrans wants to merge 3 commits into
jebrans wants to merge 3 commits into
Conversation
Two follow-ups to #2914, both on the client-hosted agent path. Disconnect cleanup swallowed every error. `removeClientAgent` failing is how a client agent leaks onto the shared dispatcher, and `leaveConversation` failing keeps a dispatcher alive with its idle timer never starting -- both then surface much later with nothing pointing back at the cause. Trace them on `agent-server:connection:error`. Not retried: removal is idempotent and ownership-checked, so a second attempt could only race a reconnect that has legitimately reclaimed the instance. `createMux` builds its method set from whichever proxy created the group, and `getManifestKey` hashes schema text only, so two builds can share a schema and still implement different methods. A device with a different `agentInterface` joined and appeared to support methods it does not, and the call only failed once someone made it. Compare the interface at join, alongside the schema, and for a replacement too, since replacing in place keeps the original mux. Only compared when both sides declared one, so a client that sends none is unaffected. Tests: 4 cases (115 passed, 111 before). Mutation-checked -- disabling the interface check fails both rejection cases and leaves both acceptance cases passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Follow-ups to the review of the method-set check. agentInterface was optional, so the check skipped whenever it was absent. It cannot be absent: registerClientAgent declares it required and createAgentRpcClient dereferences it to build the proxy, so a registration that reaches the registry always has one. The optional branch was dead code that only weakened the check. It is now required and typed AgentInterfaceFunctionName[] rather than string[], and the two undefined guards are gone. The check also rejected a lone device that upgraded its app: same schema, one more method, and the reconnect failed while its stale instance was still in the group - told to disconnect devices that do not exist. When the registration takes over the group's only instance it now adopts the new set instead. The mux has to be updated in place because the dispatcher keeps the object addDynamicAgent handed it and checks optional methods on it at call time, so replacing group.mux would leave the dispatcher on the old one. Tests: makeDevice now builds a proxy carrying exactly the methods it declares, so the cases exercise the actual misroute instead of just the string comparison. Covers both directions, the empty set, a plain reconnect, and a lone device gaining and losing a method. Mutation-checked: disabling rebuildMux fails only the two mux cases; disabling the rejection fails only the four rejection cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Android client hardcoded its agentInterface as ["executeAction"] while handleAndroidDeviceInvoke separately hardcoded the same string as its guard. Nothing tied the two together, and no CI job builds this module, so adding a method to one and not the other would go unnoticed. That drift is exactly what the server now rejects at join time, and it fails in the worse direction: a device that declares a method it cannot answer is routed the call and fails only when someone makes it. Both now come from AndroidDeviceAgent.SUPPORTED_METHODS. The test asserts the declared array against that list rather than a literal, and pins that an unimplemented method is not claimed - widening the list without adding dispatch fails the test. Verified by hand, since nothing in CI covers android/: gradlew assembleDebug and testDebugUnitTest both pass (171 tests). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.
Two follow-ups to #2914, both on the client-hosted agent path. Independent of each other; either can be dropped.
1. Disconnect cleanup was silent
connectionHandler.tsswallowed every failure from the disconnect path:removeClientAgentfailing is exactly how a client agent leaks onto the shared dispatcher.leaveConversationfailing keeps a dispatcher alive and its idle timer from ever starting.Both then surface much later as unrelated-looking failures, with nothing pointing back at the cause. There is no one to report them to -- the socket is already gone -- so they are traced on
agent-server:connection:error.Not retried, on purpose. Removal is idempotent and ownership-checked (
removeClientAgentInstancereturns false when the instance is absent or belongs to another connection), so a second attempt could only race a reconnect that has legitimately reclaimed the instance.2. A device could advertise methods it does not implement
createMuxbuilds its method set fromObject.keys(template)on whichever proxy created the group, andgetManifestKeyhashes schema text only. Two app builds can therefore share a schema and still implement a differentagentInterface. The narrower device joined and silently appeared to support the richer set; the call only failed once someone actually made it.This compares the normalized
agentInterfaceat join, next to the existing schema check:getManifestKeyalready avoids for Android'sorg.json.JSONObject).agentInterfaceis required, so there is no client that skips the check.registerClientAgentdeclares it required andcreateAgentRpcClientdereferences it to build the proxy, so a registration that reaches the registry always carries one.addDynamicAgenthanded it and checks optional methods on it at call time.Deliberately kept out of
getManifestKey: a separate key lets the error say which of the two things actually differs.Not reachable in production today -- the Android agent's
agentInterfaceis["executeAction"]only -- but it becomes live the moment that list grows.3. The Android client could grow that list without noticing
AndroidDeviceAgenthardcodedagentInterfaceas["executeAction"], andhandleAndroidDeviceInvokeseparately hardcoded the same string as its dispatch guard. Nothing tied the two together, and no CI job buildsandroid/, so adding a method to one and not the other would go unnoticed -- producing exactly the mismatch above, in the direction that fails at the call.Both now come from
AndroidDeviceAgent.SUPPORTED_METHODS. The unit test asserts the declared array against that list rather than a literal, and pins that an unimplemented method is not claimed, so widening the list without adding dispatch fails the test.Tests
8 new cases in
clientAgentRegistry.spec.ts.makeDevicebuilds a proxy carrying exactly the methods it declares, so the cases exercise the actual misroute rather than only the string comparison: a second device with fewer methods is rejected (and the mux keeps the method it advertises); one with extra methods is rejected; the same set reordered is accepted; the empty set is compared like any other; a plain reconnect keeps its slot; a lone device gaining a method has it routed through the same mux; a lone device losing one has it taken off the mux; and a reconnecting device cannot change a shared group's set.Mutation-checked. Disabling the rejection fails only the four rejection cases; disabling the in-place mux update fails only the two cases that assert on the mux. Acceptance cases pass throughout.
Verification
pnpm --filter agent-server build-- clean.pnpm --filter agent-server test-- 119 passed (111 before).npm run code-lint -- --ratchet --base origin/main--violations base 6 -> head 6, OK.pnpm run prettier:changed-- clean.gradlew assembleDebug testDebugUnitTest(mobile-2) -- 171 passed; nothing in CI coversandroid/.