diff --git a/CHANGELOG.md b/CHANGELOG.md index f93cf799..0046d0c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,9 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Packages without a separate changelog are covered by the cross-package notes below. -## [Unreleased] +## [Unreleased - Patch] + +- Agent realtime clients reuse their direct node token across reconnect attempts instead of minting a new one per attempt. Minting rotates the token through the workspace's shared write capacity, so a client that could not connect would generate write load with every retry and crowd out ordinary writes — including binding a newly spawned agent to its node. ## [8.11.0] - 2026-09-17 diff --git a/packages/sdk-typescript/CHANGELOG.md b/packages/sdk-typescript/CHANGELOG.md index dab5e3c2..ff6af8a0 100644 --- a/packages/sdk-typescript/CHANGELOG.md +++ b/packages/sdk-typescript/CHANGELOG.md @@ -7,7 +7,11 @@ See the [root changelog](../../CHANGELOG.md) for cross-package release highlight The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - Patch] + +### Fixed + +- `AgentClient.connect()` reuses its direct node token across reconnect attempts rather than calling `POST /v1/agent/node-token` on every attempt. The cached token is replaced after three consecutive attempts that never reach a usable connection, and the counter resets once one does, so a healthy client mints once per session while a client holding a rejected token still recovers. ## [8.9.0] - 2026-09-11 diff --git a/packages/sdk-typescript/src/__tests__/agent-ws.test.ts b/packages/sdk-typescript/src/__tests__/agent-ws.test.ts index b3a1a0cc..e40de74c 100644 --- a/packages/sdk-typescript/src/__tests__/agent-ws.test.ts +++ b/packages/sdk-typescript/src/__tests__/agent-ws.test.ts @@ -542,6 +542,51 @@ describe('AgentClient WebSocket integration', () => { expect(handler).toHaveBeenLastCalledWith(2); }); + it('reuses the direct node token across reconnect attempts instead of minting per attempt', async () => { + const agent = createAgent(); + const mints = () => + mockFetch.mock.calls.filter(([url]) => String(url).endsWith('/v1/agent/node-token')).length; + + agent.connect(); + await nextSocket(); + expect(mints()).toBe(1); + + // Minting rotates the node token through the workspace's shared write lane, so an + // attempt that never opens must not mint again — otherwise write backpressure + // feeds itself and a reconnect loop saturates the lane it is already losing. + for (const [index, delayMs] of [[0, 1000], [1, 2000]] as const) { + MockWebSocket.instances[index]!.simulateClose(); + await vi.advanceTimersByTimeAsync(delayMs); + await nextSocket(index + 1); + expect(mints()).toBe(1); + } + + // Reuse stays bounded so a token the server no longer accepts is still replaced. + MockWebSocket.instances[2]!.simulateClose(); + await vi.advanceTimersByTimeAsync(4000); + await nextSocket(3); + expect(mints()).toBe(2); + }); + + it('keeps a single minted token while a connection that opens keeps flapping', async () => { + const agent = createAgent(); + const mints = () => + mockFetch.mock.calls.filter(([url]) => String(url).endsWith('/v1/agent/node-token')).length; + + agent.connect(); + for (let attempt = 0; attempt < 4; attempt += 1) { + const socket = await nextSocket(attempt); + socket.simulateOpen(); + // `open` is emitted only after node registration is sent, so let that settle: + // reaching a usable connection is what marks the cached token good. + await vi.advanceTimersByTimeAsync(0); + socket.simulateClose(); + await vi.advanceTimersByTimeAsync(1000); + } + + expect(mints()).toBe(1); + }); + it('on.permanentlyDisconnected fires with attempt count', async () => { const agent = createAgent({ ws: { maxReconnectAttempts: 0, reconnectJitter: false }, diff --git a/packages/sdk-typescript/src/agent.ts b/packages/sdk-typescript/src/agent.ts index ae2a66a3..a43e1b78 100644 --- a/packages/sdk-typescript/src/agent.ts +++ b/packages/sdk-typescript/src/agent.ts @@ -140,6 +140,11 @@ type DirectNodeToken = { token: string; }; +// Consecutive connect attempts a cached node token may serve before it is re-minted. +// The counter resets whenever a connection actually opens, so a healthy client mints +// once per session while a client that can never connect still recovers a stale token. +const DIRECT_NODE_TOKEN_MAX_USES = 3; + function normalizeSubscriptionChannel(channel: string): string { const trimmed = channel.trim(); if (trimmed === '@self') return trimmed; @@ -164,6 +169,7 @@ export class AgentClient { private pendingHeartbeat: Promise | null = null; private wsOptions: Omit; private directNodeToken: DirectNodeToken | null = null; + private directNodeTokenUses = 0; private manualSubscriptions = new Set(); private managedSubscriptions = new Map(); private activeWsChannels = new Set(); @@ -230,8 +236,18 @@ export class AgentClient { } private async fetchDirectNodeToken(): Promise { + // A node token is a long-lived credential, so reuse it across reconnects rather + // than minting one per attempt. Minting rotates the node's token through the + // workspace's shared write lane, so a reconnect loop that mints every attempt + // turns write backpressure into more write load. Reuse is bounded so a token the + // server no longer accepts still gets replaced instead of wedging the client. + if (this.directNodeToken && this.directNodeTokenUses < DIRECT_NODE_TOKEN_MAX_USES) { + this.directNodeTokenUses += 1; + return this.directNodeToken.token; + } const token = await this.client.post('/v1/agent/node-token', {}); this.directNodeToken = token; + this.directNodeTokenUses = 1; return token.token; } @@ -268,6 +284,7 @@ export class AgentClient { this.client.internalOrigin, )); this.ws.on('open', () => { + this.directNodeTokenUses = 0; void this.presence.markOnline().catch(() => {}); this.startAutoHeartbeat(); this.syncDesiredSubscriptions({ resetRemoteState: true });