Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 34562de

Browse files
authored
fix(auth): improved refresh token logic & token handling (#1066)
## Problem The PostHog MCP server disconnects after some time and can't recover. Apparently, the problem was a combination of factors: - Refreshing of OAuth tokens was happening in the background even if we had active agent sessions. Since the OAuth token is deleted from the database when it is refreshed, it exposes two bugs: - The MCP server couldn't find the old token while the session was still in progress, so it would throw 401. - The Claude Code adapter was opening a second ghost session by retrieving tools using the MCP SDK, so Claude Code was actually using two connections (it explained why I saw both regular tools and mcp-cli commands in some screenshots). The ghost session was never cleaned up, so when Claude Code received the updated MCP credentials, it preferred to use an open session with a stale token (it's most likely a bug in CC). - `mcp-cli` was too experimental and didn't handle reconnections. I'm not sure here 100% because I think it might have handled reconnections, but tools were injected into the context by the ghost session, so the agent was obviously preferring contextual. However, it was removed from the new version of CC☹️ - The OAuth token update was too close to expiration (5m). As it appears, it was not enough for most internal usage sessions, since we've received a few reports of broken connections to the MCP. - Related PR PostHog/posthog#49269 to update caching TTL of tokens, as the broken OAuth refresh was partially masked by the increased TTL of refreshed tokens. ## Changes - Bumped token refresh from 5 minutes before expiration to 30 minutes. This gives enough time to find a window between active sessions and to propagate changes. 20 minutes might be fine, but sessions have become longer nowadays. - Implemented smarter OAuth token refresh: If there are no active sessions, update. If there are active sessions, look for a window when they will all become inactive. Otherwise, update if we're very close to token expiration. - Replaced the experimental MCP CLI with tool offloading. I'll additionally work on the MCP to increase the probability of tool discovery. - Bumped CC to the new version, which required adding Sonnet 4.6: PostHog/posthog#49310. - Fixed the ghost MCP session (next PR in the stack). ## Tests Feels like this requires an e2e test?
1 parent b7ae794 commit 34562de

10 files changed

Lines changed: 126 additions & 27 deletions

File tree

‎apps/twig/src/main/services/agent/schemas.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ export const subscribeSessionInput = z.object({
187187
export const AgentServiceEvent = {
188188
SessionEvent: "session-event",
189189
PermissionRequest: "permission-request",
190+
SessionsIdle: "sessions-idle",
190191
} as const;
191192

192193
export interface AgentSessionEventPayload {
@@ -205,6 +206,7 @@ export type PermissionRequestPayload = Omit<
205206
export interface AgentServiceEvents {
206207
[AgentServiceEvent.SessionEvent]: AgentSessionEventPayload;
207208
[AgentServiceEvent.PermissionRequest]: PermissionRequestPayload;
209+
[AgentServiceEvent.SessionsIdle]: undefined;
208210
}
209211

210212
// Permission response input for tRPC

‎apps/twig/src/main/services/agent/service.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,18 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
368368
this.pendingPermissions.delete(key);
369369
}
370370

371+
/**
372+
* Check if any sessions are currently active (i.e. have a prompt pending).
373+
*/
374+
public hasActiveSessions(): boolean {
375+
for (const session of this.sessions.values()) {
376+
if (session.promptPending) {
377+
return true;
378+
}
379+
}
380+
return false;
381+
}
382+
371383
private getToken(fallback: string): string {
372384
return this.currentToken || fallback;
373385
}
@@ -778,6 +790,10 @@ export class AgentService extends TypedEventEmitter<AgentServiceEvents> {
778790
} finally {
779791
session.promptPending = false;
780792
this.sleepService.release(sessionId);
793+
794+
if (!this.hasActiveSessions()) {
795+
this.emit(AgentServiceEvent.SessionsIdle, undefined);
796+
}
781797
}
782798
}
783799

‎apps/twig/src/main/trpc/routers/agent.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,19 @@ export const agentRouter = router({
142142
getService().notifySessionContext(input.sessionId, input.context),
143143
),
144144

145+
hasActiveSessions: publicProcedure.query(() =>
146+
getService().hasActiveSessions(),
147+
),
148+
149+
onSessionsIdle: publicProcedure.subscription(async function* (opts) {
150+
const service = getService();
151+
for await (const _ of service.toIterable(AgentServiceEvent.SessionsIdle, {
152+
signal: opts.signal,
153+
})) {
154+
yield true;
155+
}
156+
}),
157+
145158
markAllForRecreation: publicProcedure.mutation(() =>
146159
getService().markAllSessionsForRecreation(),
147160
),

‎apps/twig/src/renderer/features/auth/stores/authStore.ts‎

Lines changed: 76 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
OAUTH_SCOPE_VERSION,
1010
OAUTH_SCOPES,
1111
TOKEN_REFRESH_BUFFER_MS,
12+
TOKEN_REFRESH_FORCE_MS,
1213
} from "@shared/constants/oauth";
1314
import { ANALYTICS_EVENTS } from "@shared/types/analytics";
1415
import type { CloudRegion } from "@shared/types/oauth";
@@ -98,6 +99,75 @@ interface AuthState {
9899

99100
let refreshTimeoutId: number | null = null;
100101

102+
function isTokenExpiringSoon(tokenExpiry: number | null): boolean {
103+
return (
104+
tokenExpiry != null && tokenExpiry - Date.now() <= TOKEN_REFRESH_FORCE_MS
105+
);
106+
}
107+
108+
async function attemptRefreshWithActivityCheck(
109+
getState: () => AuthState,
110+
): Promise<void> {
111+
try {
112+
// If the token is about to expire, skip the activity check and refresh immediately
113+
if (isTokenExpiringSoon(getState().tokenExpiry)) {
114+
log.warn(
115+
"Token expiring imminently, forcing refresh despite active sessions",
116+
);
117+
await getState().refreshAccessToken();
118+
return;
119+
}
120+
121+
// Refresh if there are no active sessions
122+
const hasActive = await trpcVanilla.agent.hasActiveSessions.query();
123+
if (!hasActive) {
124+
await getState().refreshAccessToken();
125+
return;
126+
}
127+
128+
await new Promise<void>((resolve, reject) => {
129+
let settled = false;
130+
const settle = (reason: string, fn: () => Promise<void>) => {
131+
if (settled) return;
132+
settled = true;
133+
subscription.unsubscribe();
134+
window.clearInterval(expiryCheckId);
135+
log.info(`Settling activity wait: ${reason}`);
136+
fn().then(resolve).catch(reject);
137+
};
138+
139+
// Subscribe to the idle event
140+
const subscription = trpcVanilla.agent.onSessionsIdle.subscribe(
141+
undefined,
142+
{
143+
onData: () =>
144+
settle("sessions idle", () => getState().refreshAccessToken()),
145+
onError: (error) => {
146+
log.warn(
147+
"Sessions idle subscription failed, refreshing anyway",
148+
error,
149+
);
150+
settle("subscription error", () => getState().refreshAccessToken());
151+
},
152+
},
153+
);
154+
155+
// Safety net: if the token is about to expire while we wait, force refresh
156+
const expiryCheckId = window.setInterval(() => {
157+
if (isTokenExpiringSoon(getState().tokenExpiry)) {
158+
settle("token expiring imminently", () =>
159+
getState().refreshAccessToken(),
160+
);
161+
}
162+
}, TOKEN_REFRESH_FORCE_MS / 2);
163+
});
164+
} catch (error) {
165+
// IPC call failed — refresh anyway (better than letting the token expire)
166+
log.warn("Activity check failed, refreshing token anyway", error);
167+
await getState().refreshAccessToken();
168+
}
169+
}
170+
101171
export const useAuthStore = create<AuthState>()(
102172
subscribeWithSelector(
103173
persist(
@@ -399,18 +469,14 @@ export const useAuthStore = create<AuthState>()(
399469

400470
if (timeUntilRefresh > 0) {
401471
refreshTimeoutId = window.setTimeout(() => {
402-
get()
403-
.refreshAccessToken()
404-
.catch((error) => {
405-
log.error("Proactive token refresh failed:", error);
406-
});
472+
attemptRefreshWithActivityCheck(get).catch((error) => {
473+
log.error("Proactive token refresh failed:", error);
474+
});
407475
}, timeUntilRefresh);
408476
} else {
409-
get()
410-
.refreshAccessToken()
411-
.catch((error) => {
412-
log.error("Immediate token refresh failed:", error);
413-
});
477+
attemptRefreshWithActivityCheck(get).catch((error) => {
478+
log.error("Immediate token refresh failed:", error);
479+
});
414480
}
415481
},
416482

‎apps/twig/src/shared/constants/oauth.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ export const REGION_LABELS: Record<CloudRegion, string> = {
4747
};
4848

4949
// Token refresh settings
50-
export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes before expiry
50+
export const TOKEN_REFRESH_BUFFER_MS = 30 * 60 * 1000; // 30 minutes before expiry
51+
export const TOKEN_REFRESH_FORCE_MS = 60 * 1000; // Force refresh when <1 min to expiry, even with active sessions
5152

5253
export function getCloudUrlFromRegion(region: CloudRegion): string {
5354
switch (region) {

‎packages/agent/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@
8585
},
8686
"dependencies": {
8787
"@agentclientprotocol/sdk": "^0.14.0",
88-
"@anthropic-ai/claude-agent-sdk": "0.2.58",
88+
"@anthropic-ai/claude-agent-sdk": "0.2.59",
8989
"@anthropic-ai/sdk": "^0.71.0",
9090
"@hono/node-server": "^1.19.9",
9191
"@modelcontextprotocol/sdk": "^1.25.3",

‎packages/agent/src/adapters/claude/claude-agent.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import { v7 as uuidv7 } from "uuid";
3333
import packageJson from "../../../package.json" with { type: "json" };
3434
import type { SessionContext } from "../../otel-log-writer.js";
3535
import type { SessionLogWriter } from "../../session-log-writer.js";
36-
import { withTimeout } from "../../utils/common.js";
36+
import { unreachable, withTimeout } from "../../utils/common.js";
3737
import { Logger } from "../../utils/logger.js";
3838
import { Pushable } from "../../utils/streams.js";
3939
import { BaseAcpAgent } from "../base-acp-agent.js";
@@ -646,9 +646,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
646646
return null;
647647

648648
default:
649-
this.logger.warn("Unhandled message type", {
650-
type: (message as { type: string }).type,
651-
});
649+
// SDKMessage union includes undefined types (SDKRateLimitEvent, SDKPromptSuggestionMessage)
650+
// that resolve to `any`, preventing exhaustive narrowing
651+
unreachable(message as never, this.logger);
652652
return null;
653653
}
654654
}

‎packages/agent/src/adapters/claude/session/options.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ function buildEnvironment(): Record<string, string> {
8888
...process.env,
8989
ELECTRON_RUN_AS_NODE: "1",
9090
CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL: "true",
91-
ENABLE_EXPERIMENTAL_MCP_CLI: "true",
91+
// Offload all MCP tools by default
92+
ENABLE_TOOL_SEARCH: "auto:0",
9293
};
9394
}
9495

‎packages/agent/src/agent.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export class Agent {
6969
options: TaskExecutionOptions = {},
7070
): Promise<InProcessAcpConnection> {
7171
const gatewayConfig = this._configureLlmGateway(options.adapter);
72-
72+
this.logger.info("Configured LLM gateway", options);
7373
this.taskRunId = taskRunId;
7474

7575
let allowedModelIds: Set<string> | undefined;

‎pnpm-lock.yaml‎

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)