Skip to content

Trace client agent cleanup failures and pin the group's method set - #2963

Open
jebrans wants to merge 3 commits into
mainfrom
dev/jebransyed/client-agent-followups
Open

Trace client agent cleanup failures and pin the group's method set#2963
jebrans wants to merge 3 commits into
mainfrom
dev/jebransyed/client-agent-followups

Conversation

@jebrans

@jebrans jebrans commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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.ts swallowed every failure from the disconnect path:

  • removeClientAgent failing is exactly how a client agent leaks onto the shared dispatcher.
  • leaveConversation failing 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 (removeClientAgentInstance returns 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

createMux builds its method set from Object.keys(template) on whichever proxy created the group, and getManifestKey hashes schema text only. Two app builds can therefore share a schema and still implement a different agentInterface. 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 agentInterface at join, next to the existing schema check:

  • Covers a replacement too, not just a new instance -- replacing in place keeps the mux built from the original proxy.
  • Order-insensitive and de-duplicated, so key order cannot cause a false mismatch (same trap getManifestKey already avoids for Android's org.json.JSONObject).
  • agentInterface is required, so there is no client that skips the check. registerClientAgent declares it required and createAgentRpcClient dereferences it to build the proxy, so a registration that reaches the registry always carries one.
  • A lone device that upgrades its app (same schema, method set grows or shrinks) adopts the new set instead of being rejected. Nobody else is sharing the name, so there is no other device to conflict with -- and nothing for the user to disconnect. The mux is updated in place, because the dispatcher keeps the object addDynamicAgent handed 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 agentInterface is ["executeAction"] only -- but it becomes live the moment that list grows.

3. The Android client could grow that list without noticing

AndroidDeviceAgent hardcoded agentInterface as ["executeAction"], and handleAndroidDeviceInvoke separately hardcoded the same string as its dispatch guard. Nothing tied the two together, and no CI job builds android/, 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. makeDevice builds 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 covers android/.

jebrans and others added 3 commits September 2, 2026 11:45
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>
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.

1 participant