Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion packages/sdk-typescript/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 45 additions & 0 deletions packages/sdk-typescript/src/__tests__/agent-ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
17 changes: 17 additions & 0 deletions packages/sdk-typescript/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -164,6 +169,7 @@ export class AgentClient {
private pendingHeartbeat: Promise<void> | null = null;
private wsOptions: Omit<WsClientOptions, 'token' | 'baseUrl' | 'path' | 'nodeRegistration' | 'autoAckDeliveries'>;
private directNodeToken: DirectNodeToken | null = null;
private directNodeTokenUses = 0;
private manualSubscriptions = new Set<string>();
private managedSubscriptions = new Map<symbol, ManagedSubscription>();
private activeWsChannels = new Set<string>();
Expand Down Expand Up @@ -230,8 +236,18 @@ export class AgentClient {
}

private async fetchDirectNodeToken(): Promise<string> {
// 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<DirectNodeToken>('/v1/agent/node-token', {});
this.directNodeToken = token;
this.directNodeTokenUses = 1;
return token.token;
}

Expand Down Expand Up @@ -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 });
Expand Down
Loading