From 1a4fc8dea621dc2a3d8434e13ed065a59ccbb311 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 17 Jan 2026 08:03:53 +0000 Subject: [PATCH] fix: guard memory sync errors --- CHANGELOG.md | 1 + .../manager.sync-errors-do-not-crash.test.ts | 92 +++++++++++++++++++ src/memory/manager.ts | 11 ++- 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 src/memory/manager.sync-errors-do-not-crash.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 423e22455..1cb9a37f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,7 @@ - Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt. - Sessions: include deliveryContext in sessions.list and reuse normalized delivery routing for announce/restart fallbacks. (#1058) - Sessions: propagate deliveryContext into last-route updates to keep account/channel routing stable. (#1058) +- Memory: prevent unhandled rejections when watch/interval sync fails. (#1076) — thanks @roshanasingh4. - Gateway: honor explicit delivery targets without implicit accountId fallback; preserve lastAccountId for implicit routing. - Gateway: avoid reusing last-to/accountId when the requested channel differs; sync deliveryContext with last route fields. - Repo: fix oxlint config filename and move ignore pattern into config. (#1064) — thanks @connorshea. diff --git a/src/memory/manager.sync-errors-do-not-crash.test.ts b/src/memory/manager.sync-errors-do-not-crash.test.ts new file mode 100644 index 000000000..0c1d91417 --- /dev/null +++ b/src/memory/manager.sync-errors-do-not-crash.test.ts @@ -0,0 +1,92 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; + +vi.mock("chokidar", () => ({ + default: { + watch: vi.fn(() => ({ + on: vi.fn(), + close: vi.fn(async () => undefined), + })), + }, +})); + +vi.mock("./embeddings.js", () => { + return { + createEmbeddingProvider: async () => ({ + requestedProvider: "openai", + provider: { + id: "mock", + model: "mock-embed", + embedQuery: async () => [0, 0, 0], + embedBatch: async () => { + throw new Error("openai embeddings failed: 429 insufficient_quota"); + }, + }, + }), + }; +}); + +describe("memory manager sync failures", () => { + let workspaceDir: string; + let indexPath: string; + let manager: MemoryIndexManager | null = null; + + beforeEach(async () => { + vi.useFakeTimers(); + workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-mem-")); + indexPath = path.join(workspaceDir, "index.sqlite"); + await fs.mkdir(path.join(workspaceDir, "memory")); + await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "Hello"); + }); + + afterEach(async () => { + vi.useRealTimers(); + if (manager) { + await manager.close(); + manager = null; + } + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + + it("does not raise unhandledRejection when watch-triggered sync fails", async () => { + const unhandled: unknown[] = []; + const handler = (reason: unknown) => { + unhandled.push(reason); + }; + process.on("unhandledRejection", handler); + + const cfg = { + agents: { + defaults: { + workspace: workspaceDir, + memorySearch: { + provider: "openai", + model: "mock-embed", + store: { path: indexPath }, + sync: { watch: true, watchDebounceMs: 1, onSessionStart: false, onSearch: false }, + }, + }, + list: [{ id: "main", default: true }], + }, + }; + + const result = await getMemorySearchManager({ cfg, agentId: "main" }); + expect(result.manager).not.toBeNull(); + if (!result.manager) throw new Error("manager missing"); + manager = result.manager; + + // Call the internal scheduler directly; it uses fire-and-forget sync. + (manager as unknown as { scheduleWatchSync: () => void }).scheduleWatchSync(); + + await vi.runAllTimersAsync(); + await Promise.resolve(); + + process.off("unhandledRejection", handler); + expect(unhandled).toHaveLength(0); + }); +}); diff --git a/src/memory/manager.ts b/src/memory/manager.ts index 2e7066011..d2ef57010 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -8,6 +8,7 @@ import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope import type { ResolvedMemorySearchConfig } from "../agents/memory-search.js"; import { resolveMemorySearchConfig } from "../agents/memory-search.js"; import type { ClawdbotConfig } from "../config/config.js"; +import { createSubsystemLogger } from "../logging.js"; import { resolveUserPath, truncateUtf16Safe } from "../utils.js"; import { createEmbeddingProvider, @@ -46,6 +47,8 @@ type MemoryIndexMeta = { const META_KEY = "memory_index_meta_v1"; const SNIPPET_MAX_CHARS = 700; +const log = createSubsystemLogger("memory"); + const INDEX_CACHE = new Map(); export class MemoryIndexManager { @@ -314,7 +317,9 @@ export class MemoryIndexManager { if (!minutes || minutes <= 0 || this.intervalTimer) return; const ms = minutes * 60 * 1000; this.intervalTimer = setInterval(() => { - void this.sync({ reason: "interval" }); + void this.sync({ reason: "interval" }).catch((err) => { + log.warn(`memory sync failed (interval): ${String(err)}`); + }); }, ms); } @@ -323,7 +328,9 @@ export class MemoryIndexManager { if (this.watchTimer) clearTimeout(this.watchTimer); this.watchTimer = setTimeout(() => { this.watchTimer = null; - void this.sync({ reason: "watch" }); + void this.sync({ reason: "watch" }).catch((err) => { + log.warn(`memory sync failed (watch): ${String(err)}`); + }); }, this.settings.sync.watchDebounceMs); }