Skip to content

Commit 545952e

Browse files
committed
fix: report session doctor cache health
Expose Redis-backed graphiti cache health through session_doctor so live runtime checks reflect the actual local hot-tier state.
1 parent 64a0bc8 commit 545952e

4 files changed

Lines changed: 259 additions & 13 deletions

File tree

src/index.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,7 @@ describe("index", () => {
651651
assertEquals(records.redisCloseCalls, 1);
652652
assertEquals(records.sessionMcpRuntimeArgs, [{
653653
redisClient: records.redisClientInstances[0],
654+
graphitiCache: records.redisCacheInstances[0],
654655
sessionTtlSeconds: config.redis.sessionTtlSeconds,
655656
groupId: "group-id",
656657
}]);
@@ -905,6 +906,7 @@ describe("index", () => {
905906

906907
assertEquals(records.sessionMcpRuntimeArgs, [{
907908
redisClient: records.redisClientInstances[0],
909+
graphitiCache: records.redisCacheInstances[0],
908910
sessionTtlSeconds: config.redis.sessionTtlSeconds,
909911
groupId: "group-id",
910912
}]);

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ export const graphiti: Plugin = (
206206
);
207207
const sessionMcpRuntime = dependencies.createSessionMcpRuntime({
208208
redisClient,
209+
graphitiCache: redisCache,
209210
sessionTtlSeconds: config.redis.sessionTtlSeconds,
210211
groupId: defaultGroupId,
211212
});

src/services/session-mcp-runtime.test.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,100 @@ import {
1717
} from "./session-mcp-types.ts";
1818
import { RedisClient } from "./redis-client.ts";
1919

20+
type RedisEvent = "close" | "end" | "error" | "ready";
21+
22+
class DoctorRedisRuntime {
23+
private readonly listeners = new Map<
24+
RedisEvent,
25+
Set<(...args: unknown[]) => void>
26+
>();
27+
28+
connect(): Promise<void> {
29+
this.emit("ready");
30+
return Promise.resolve();
31+
}
32+
33+
ping(): Promise<"PONG"> {
34+
return Promise.resolve("PONG");
35+
}
36+
37+
quit(): Promise<"OK"> {
38+
return Promise.resolve("OK");
39+
}
40+
41+
lpush(): Promise<number> {
42+
return Promise.resolve(0);
43+
}
44+
45+
rpush(): Promise<number> {
46+
return Promise.resolve(0);
47+
}
48+
49+
lmove(): Promise<string | null> {
50+
return Promise.resolve(null);
51+
}
52+
53+
lrange(): Promise<string[]> {
54+
return Promise.resolve([]);
55+
}
56+
57+
llen(): Promise<number> {
58+
return Promise.resolve(0);
59+
}
60+
61+
ltrim(): Promise<void> {
62+
return Promise.resolve();
63+
}
64+
65+
lindex(): Promise<string | null> {
66+
return Promise.resolve(null);
67+
}
68+
69+
lset(): Promise<void> {
70+
return Promise.resolve();
71+
}
72+
73+
get(): Promise<string | null> {
74+
return Promise.resolve(null);
75+
}
76+
77+
set(): Promise<"OK"> {
78+
return Promise.resolve("OK");
79+
}
80+
81+
expire(): Promise<number> {
82+
return Promise.resolve(1);
83+
}
84+
85+
del(): Promise<number> {
86+
return Promise.resolve(0);
87+
}
88+
89+
hset(): Promise<number> {
90+
return Promise.resolve(0);
91+
}
92+
93+
hgetall(): Promise<Record<string, string>> {
94+
return Promise.resolve({});
95+
}
96+
97+
on(event: RedisEvent, listener: (...args: unknown[]) => void): void {
98+
const listeners = this.listeners.get(event) ?? new Set();
99+
listeners.add(listener);
100+
this.listeners.set(event, listeners);
101+
}
102+
103+
off(event: RedisEvent, listener: (...args: unknown[]) => void): void {
104+
this.listeners.get(event)?.delete(listener);
105+
}
106+
107+
private emit(event: RedisEvent, ...args: unknown[]): void {
108+
for (const listener of this.listeners.get(event) ?? []) {
109+
listener(...args);
110+
}
111+
}
112+
}
113+
20114
const textEncoder = new TextEncoder();
21115

22116
const toolContext = {
@@ -106,6 +200,106 @@ describe("session-mcp-runtime", () => {
106200
}
107201
});
108202

203+
it("reports live redis health in session_doctor when a redis client is provided", async () => {
204+
const degradedRedis = new RedisClient({ endpoint: "redis://unused" });
205+
const degradedRuntime = createSessionMcpRuntime({
206+
redisClient: degradedRedis,
207+
sessionTtlSeconds: 60,
208+
});
209+
const connectedRedis = new RedisClient({
210+
endpoint: "redis://unused",
211+
runtimeFactory: () => new DoctorRedisRuntime(),
212+
});
213+
const connectedRuntime = createSessionMcpRuntime({
214+
redisClient: connectedRedis,
215+
sessionTtlSeconds: 60,
216+
});
217+
218+
try {
219+
const degradedSerialized = await degradedRuntime.tools.session_doctor
220+
.execute(
221+
validRequests.session_doctor,
222+
toolContext,
223+
);
224+
const degraded = JSON.parse(degradedSerialized);
225+
226+
assertEquals(degraded.runtime.status, "ok");
227+
assertEquals(degraded.redis.status, "degraded");
228+
229+
await connectedRedis.connect();
230+
231+
const connectedSerialized = await connectedRuntime.tools.session_doctor
232+
.execute(
233+
validRequests.session_doctor,
234+
toolContext,
235+
);
236+
const connected = JSON.parse(connectedSerialized);
237+
238+
assertEquals(connected.runtime.status, "ok");
239+
assertEquals(connected.redis.status, "ok");
240+
assertEquals(connected.graphiti_cache.status, "not_checked");
241+
} finally {
242+
await degradedRuntime.dispose();
243+
await degradedRedis.close();
244+
await connectedRuntime.dispose();
245+
await connectedRedis.close();
246+
}
247+
});
248+
249+
it("reports local graphiti cache health in session_doctor", async () => {
250+
const disconnectedRedis = new RedisClient({ endpoint: "redis://unused" });
251+
const connectedRedis = new RedisClient({
252+
endpoint: "redis://unused",
253+
runtimeFactory: () => new DoctorRedisRuntime(),
254+
});
255+
256+
const noCacheRuntime = createSessionMcpRuntime();
257+
const degradedCacheRuntime = createSessionMcpRuntime({
258+
redisClient: disconnectedRedis,
259+
sessionTtlSeconds: 60,
260+
graphitiCache: {},
261+
});
262+
const connectedCacheRuntime = createSessionMcpRuntime({
263+
redisClient: connectedRedis,
264+
sessionTtlSeconds: 60,
265+
graphitiCache: {},
266+
});
267+
268+
try {
269+
const noCache = JSON.parse(
270+
await noCacheRuntime.tools.session_doctor.execute(
271+
validRequests.session_doctor,
272+
toolContext,
273+
),
274+
);
275+
assertEquals(noCache.graphiti_cache.status, "not_checked");
276+
277+
const degradedCache = JSON.parse(
278+
await degradedCacheRuntime.tools.session_doctor.execute(
279+
validRequests.session_doctor,
280+
toolContext,
281+
),
282+
);
283+
assertEquals(degradedCache.graphiti_cache.status, "degraded");
284+
285+
await connectedRedis.connect();
286+
287+
const connectedCache = JSON.parse(
288+
await connectedCacheRuntime.tools.session_doctor.execute(
289+
validRequests.session_doctor,
290+
toolContext,
291+
),
292+
);
293+
assertEquals(connectedCache.graphiti_cache.status, "ok");
294+
} finally {
295+
await noCacheRuntime.dispose();
296+
await degradedCacheRuntime.dispose();
297+
await connectedCacheRuntime.dispose();
298+
await disconnectedRedis.close();
299+
await connectedRedis.close();
300+
}
301+
});
302+
109303
it("caps serialized responses to the exact 8 KB budget", async () => {
110304
const runtime = createSessionMcpRuntime();
111305

src/services/session-mcp-runtime.ts

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type ToolDefinition,
55
} from "@opencode-ai/plugin";
66
import type { RedisClient } from "./redis-client.ts";
7+
import type { RedisCacheService } from "./redis-cache.ts";
78
import {
89
createSessionCorpusService,
910
type SessionCorpusService,
@@ -79,6 +80,7 @@ type SessionMcpHandlerMap = {
7980
type SessionMcpRuntimeOptions = {
8081
handlers?: Partial<SessionMcpHandlerMap>;
8182
redisClient?: RedisClient;
83+
graphitiCache?: RedisCacheService | object;
8284
sessionTtlSeconds?: number;
8385
groupId?: string;
8486
createSessionCorpusService?: typeof createSessionCorpusService;
@@ -93,6 +95,54 @@ export type SessionMcpRuntime = {
9395
) => Promise<void>;
9496
};
9597

98+
const getRedisDoctorStatus = (
99+
redisClient: RedisClient | undefined,
100+
): { status: "ok" | "degraded" | "not_checked"; detail: string } => {
101+
if (!redisClient) {
102+
return {
103+
status: "not_checked",
104+
detail: "Redis client is not configured for this runtime.",
105+
};
106+
}
107+
108+
if (redisClient.isConnected()) {
109+
return {
110+
status: "ok",
111+
detail: "Redis hot tier is connected.",
112+
};
113+
}
114+
115+
return {
116+
status: "degraded",
117+
detail: "Redis hot tier is unavailable; using in-memory fallback.",
118+
};
119+
};
120+
121+
const getGraphitiCacheDoctorStatus = (
122+
graphitiCache: SessionMcpRuntimeOptions["graphitiCache"],
123+
redisClient: RedisClient | undefined,
124+
): { status: "ok" | "degraded" | "not_checked"; detail: string } => {
125+
if (!graphitiCache) {
126+
return {
127+
status: "not_checked",
128+
detail: "Graphiti cache service is not configured for this runtime.",
129+
};
130+
}
131+
132+
if (redisClient?.isConnected()) {
133+
return {
134+
status: "ok",
135+
detail: "Graphiti cache is backed by the connected Redis hot tier.",
136+
};
137+
}
138+
139+
return {
140+
status: "degraded",
141+
detail:
142+
"Graphiti cache is configured but Redis is unavailable; cache access is degraded.",
143+
};
144+
};
145+
96146
const parseRequest = <TToolName extends SessionMcpToolName>(
97147
toolName: TToolName,
98148
rawRequest: unknown,
@@ -293,28 +343,27 @@ export const createSessionMcpRuntime = (
293343
bytes_saved_estimate: stats.bytesSavedEstimate,
294344
};
295345
},
296-
session_doctor: () =>
297-
Promise.resolve({
346+
session_doctor: () => {
347+
const redis = getRedisDoctorStatus(options.redisClient);
348+
const graphitiCache = getGraphitiCacheDoctorStatus(
349+
options.graphitiCache,
350+
options.redisClient,
351+
);
352+
return Promise.resolve({
298353
status: "ok",
299354
checks: [{
300355
name: "session-mcp-runtime",
301356
status: "ok",
302-
detail: "Stub runtime handlers are registered in-process.",
357+
detail: "In-process session MCP runtime handlers are registered.",
303358
}],
304-
redis: {
305-
status: "not_checked",
306-
detail: "Redis health is not checked by the Task 1 stub runtime.",
307-
},
308-
graphiti_cache: {
309-
status: "not_checked",
310-
detail:
311-
"Graphiti cache health is not checked by the Task 1 stub runtime.",
312-
},
359+
redis,
360+
graphiti_cache: graphitiCache,
313361
runtime: {
314362
status: "ok",
315363
detail: "In-process session MCP runtime is active.",
316364
},
317-
}),
365+
});
366+
},
318367
};
319368

320369
const handlerMap: SessionMcpHandlerMap = {

0 commit comments

Comments
 (0)