MAESTRO: Address PR review feedback — fix connection lifecycle, lazy channel access, buffer safety
- Move sseAbortController/connectionState from module globals into closure for multi-instance safety - Make start() idempotent by aborting existing connection before creating a new one - Track connectionPromise and await it on stop() for proper cleanup - Guard channel API access lazily to prevent crash when integrations are missing - Add 1MB MAX_SSE_BUFFER_SIZE to prevent unbounded buffer growth - Log malformed JSON parse errors instead of silently ignoring - Replace error: any with proper instanceof Error type narrowing - Remove hardcoded user paths from TESTING.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+3
-3
@@ -28,7 +28,7 @@ curl -s -N http://localhost:37777/stream --max-time 3 2>/dev/null || true
|
|||||||
**If the worker is not running:**
|
**If the worker is not running:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /Users/alexnewman/Scripts/claude-mem
|
cd /path/to/claude-mem
|
||||||
npm run build-and-sync
|
npm run build-and-sync
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ cat ~/.openclaw/openclaw.json
|
|||||||
{
|
{
|
||||||
"claude-mem": {
|
"claude-mem": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"source": "/Users/alexnewman/Scripts/claude-mem/openclaw",
|
"source": "/path/to/claude-mem/openclaw",
|
||||||
"config": {
|
"config": {
|
||||||
"syncMemoryFile": true,
|
"syncMemoryFile": true,
|
||||||
"workerPort": 37777,
|
"workerPort": 37777,
|
||||||
@@ -135,7 +135,7 @@ node test-sse-consumer.js
|
|||||||
|
|
||||||
### Worker not running
|
### Worker not running
|
||||||
- **Symptom:** Gateway logs show `SSE stream error: fetch failed. Reconnecting in 1s`
|
- **Symptom:** Gateway logs show `SSE stream error: fetch failed. Reconnecting in 1s`
|
||||||
- **Fix:** Start the worker with `cd /Users/alexnewman/Scripts/claude-mem && npm run build-and-sync`
|
- **Fix:** Start the worker with `cd /path/to/claude-mem && npm run build-and-sync`
|
||||||
|
|
||||||
### Port mismatch
|
### Port mismatch
|
||||||
- **Symptom:** SSE connection fails even though worker health check passes
|
- **Symptom:** SSE connection fails even though worker health check passes
|
||||||
|
|||||||
+62
-33
@@ -42,8 +42,7 @@ interface SSENewObservationEvent {
|
|||||||
|
|
||||||
type ConnectionState = "disconnected" | "connected" | "reconnecting";
|
type ConnectionState = "disconnected" | "connected" | "reconnecting";
|
||||||
|
|
||||||
let sseAbortController: AbortController | null = null;
|
const MAX_SSE_BUFFER_SIZE = 1024 * 1024; // 1MB
|
||||||
let connectionState: ConnectionState = "disconnected";
|
|
||||||
|
|
||||||
function formatObservationMessage(observation: ObservationSSEPayload): string {
|
function formatObservationMessage(observation: ObservationSSEPayload): string {
|
||||||
const title = observation.title || "Untitled";
|
const title = observation.title || "Untitled";
|
||||||
@@ -54,50 +53,49 @@ function formatObservationMessage(observation: ObservationSSEPayload): string {
|
|||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendToChannel(
|
function sendToChannel(
|
||||||
api: OpenClawPluginApi,
|
api: OpenClawPluginApi,
|
||||||
channel: string,
|
channel: string,
|
||||||
to: string,
|
to: string,
|
||||||
text: string
|
text: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const channelSendFunctions: Record<string, (to: string, text: string) => Promise<any>> = {
|
const channelApi = api.runtime.channel[channel];
|
||||||
telegram: api.runtime.channel.telegram.sendMessageTelegram,
|
if (!channelApi) {
|
||||||
discord: api.runtime.channel.discord.sendMessageDiscord,
|
|
||||||
signal: api.runtime.channel.signal.sendMessageSignal,
|
|
||||||
slack: api.runtime.channel.slack.sendMessageSlack,
|
|
||||||
whatsapp: api.runtime.channel.whatsapp.sendMessageWhatsApp,
|
|
||||||
line: api.runtime.channel.line.sendMessageLine,
|
|
||||||
};
|
|
||||||
|
|
||||||
const senderFunction = channelSendFunctions[channel];
|
|
||||||
if (!senderFunction) {
|
|
||||||
api.log(`[claude-mem] Unknown channel type: ${channel}`);
|
api.log(`[claude-mem] Unknown channel type: ${channel}`);
|
||||||
return;
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const sendFunctionName = `sendMessage${channel.charAt(0).toUpperCase()}${channel.slice(1)}`;
|
||||||
await senderFunction(to, text);
|
const senderFunction = channelApi[sendFunctionName];
|
||||||
} catch (error) {
|
if (!senderFunction) {
|
||||||
api.log(`[claude-mem] Failed to send to ${channel}: ${error}`);
|
api.log(`[claude-mem] Channel "${channel}" has no ${sendFunctionName} function`);
|
||||||
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return senderFunction(to, text).catch((error: unknown) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
api.log(`[claude-mem] Failed to send to ${channel}: ${message}`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectToSSEStream(
|
async function connectToSSEStream(
|
||||||
api: OpenClawPluginApi,
|
api: OpenClawPluginApi,
|
||||||
port: number,
|
port: number,
|
||||||
channel: string,
|
channel: string,
|
||||||
to: string
|
to: string,
|
||||||
|
abortController: AbortController,
|
||||||
|
setConnectionState: (state: ConnectionState) => void
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let backoffMs = 1000;
|
let backoffMs = 1000;
|
||||||
const maxBackoffMs = 30000;
|
const maxBackoffMs = 30000;
|
||||||
|
|
||||||
while (sseAbortController && !sseAbortController.signal.aborted) {
|
while (!abortController.signal.aborted) {
|
||||||
try {
|
try {
|
||||||
connectionState = "reconnecting";
|
setConnectionState("reconnecting");
|
||||||
api.log(`[claude-mem] Connecting to SSE stream at http://localhost:${port}/stream`);
|
api.log(`[claude-mem] Connecting to SSE stream at http://localhost:${port}/stream`);
|
||||||
|
|
||||||
const response = await fetch(`http://localhost:${port}/stream`, {
|
const response = await fetch(`http://localhost:${port}/stream`, {
|
||||||
signal: sseAbortController.signal,
|
signal: abortController.signal,
|
||||||
headers: { Accept: "text/event-stream" },
|
headers: { Accept: "text/event-stream" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,7 +107,7 @@ async function connectToSSEStream(
|
|||||||
throw new Error("SSE stream response has no body");
|
throw new Error("SSE stream response has no body");
|
||||||
}
|
}
|
||||||
|
|
||||||
connectionState = "connected";
|
setConnectionState("connected");
|
||||||
backoffMs = 1000;
|
backoffMs = 1000;
|
||||||
api.log("[claude-mem] Connected to SSE stream");
|
api.log("[claude-mem] Connected to SSE stream");
|
||||||
|
|
||||||
@@ -123,6 +121,11 @@ async function connectToSSEStream(
|
|||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
if (buffer.length > MAX_SSE_BUFFER_SIZE) {
|
||||||
|
api.log("[claude-mem] SSE buffer overflow, clearing buffer");
|
||||||
|
buffer = "";
|
||||||
|
}
|
||||||
|
|
||||||
const frames = buffer.split("\n\n");
|
const frames = buffer.split("\n\n");
|
||||||
buffer = frames.pop() || "";
|
buffer = frames.pop() || "";
|
||||||
|
|
||||||
@@ -142,32 +145,47 @@ async function connectToSSEStream(
|
|||||||
const message = formatObservationMessage(event.observation);
|
const message = formatObservationMessage(event.observation);
|
||||||
await sendToChannel(api, channel, to, message);
|
await sendToChannel(api, channel, to, message);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (parseError: unknown) {
|
||||||
// Ignore malformed JSON frames
|
const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
|
||||||
|
api.log(`[claude-mem] Failed to parse SSE frame: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
if (sseAbortController?.signal.aborted) {
|
if (abortController.signal.aborted) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
connectionState = "reconnecting";
|
setConnectionState("reconnecting");
|
||||||
api.log(`[claude-mem] SSE stream error: ${error.message ?? error}. Reconnecting in ${backoffMs / 1000}s`);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
api.log(`[claude-mem] SSE stream error: ${errorMessage}. Reconnecting in ${backoffMs / 1000}s`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sseAbortController?.signal.aborted) break;
|
if (abortController.signal.aborted) break;
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
||||||
backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
|
backoffMs = Math.min(backoffMs * 2, maxBackoffMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectionState = "disconnected";
|
setConnectionState("disconnected");
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
||||||
|
let sseAbortController: AbortController | null = null;
|
||||||
|
let connectionState: ConnectionState = "disconnected";
|
||||||
|
let connectionPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
api.registerService({
|
api.registerService({
|
||||||
id: "claude-mem-observation-feed",
|
id: "claude-mem-observation-feed",
|
||||||
start: async (_ctx) => {
|
start: async (_ctx) => {
|
||||||
|
// Abort any existing connection before starting a new one
|
||||||
|
if (sseAbortController) {
|
||||||
|
sseAbortController.abort();
|
||||||
|
if (connectionPromise) {
|
||||||
|
await connectionPromise;
|
||||||
|
connectionPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const config = api.getConfig();
|
const config = api.getConfig();
|
||||||
const workerPort = (config.workerPort as number) || 37777;
|
const workerPort = (config.workerPort as number) || 37777;
|
||||||
const feedConfig = config.observationFeed as
|
const feedConfig = config.observationFeed as
|
||||||
@@ -187,13 +205,24 @@ export default function claudeMemPlugin(api: OpenClawPluginApi): void {
|
|||||||
api.log(`[claude-mem] Observation feed starting — channel: ${feedConfig.channel}, target: ${feedConfig.to}`);
|
api.log(`[claude-mem] Observation feed starting — channel: ${feedConfig.channel}, target: ${feedConfig.to}`);
|
||||||
|
|
||||||
sseAbortController = new AbortController();
|
sseAbortController = new AbortController();
|
||||||
connectToSSEStream(api, workerPort, feedConfig.channel, feedConfig.to);
|
connectionPromise = connectToSSEStream(
|
||||||
|
api,
|
||||||
|
workerPort,
|
||||||
|
feedConfig.channel,
|
||||||
|
feedConfig.to,
|
||||||
|
sseAbortController,
|
||||||
|
(state) => { connectionState = state; }
|
||||||
|
);
|
||||||
},
|
},
|
||||||
stop: async (_ctx) => {
|
stop: async (_ctx) => {
|
||||||
if (sseAbortController) {
|
if (sseAbortController) {
|
||||||
sseAbortController.abort();
|
sseAbortController.abort();
|
||||||
sseAbortController = null;
|
sseAbortController = null;
|
||||||
}
|
}
|
||||||
|
if (connectionPromise) {
|
||||||
|
await connectionPromise;
|
||||||
|
connectionPromise = null;
|
||||||
|
}
|
||||||
connectionState = "disconnected";
|
connectionState = "disconnected";
|
||||||
api.log("[claude-mem] Observation feed stopped — SSE connection closed");
|
api.log("[claude-mem] Observation feed stopped — SSE connection closed");
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user