Skip to content

Commit f828fae

Browse files
Merge branch 'main' into fix/heartbeat-json-serialization-29028
2 parents e3252a9 + 1867611 commit f828fae

File tree

5 files changed

+374
-21
lines changed

5 files changed

+374
-21
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ Docs: https://docs.openclaw.ai
115115
- Security/Voice Call (Twilio): bind webhook replay + manager dedupe identity to authenticated request material, remove unsigned `i-twilio-idempotency-token` trust from replay/dedupe keys, and thread verified request identity through provider parse flow to harden cross-provider event dedupe. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting.
116116
- Security/Exec approvals forwarding: prefer turn-source channel/account/thread metadata when resolving approval delivery targets so stale session routes do not misroute approval prompts.
117117
- Security/Pairing multi-account isolation: enforce account-scoped pairing allowlists and pending-request storage across core + extension message channels while preserving channel-scoped defaults for the default account. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting and @gumadeiras for implementation.
118+
- Memory/SQLite: deduplicate concurrent memory-manager initialization and auto-reopen stale SQLite handles after atomic reindex swaps, preventing repeated `attempt to write a readonly database` sync failures until gateway restart.
118119
- Config/Plugins entries: treat unknown `plugins.entries.*` ids as startup warnings (ignored stale keys) instead of hard validation failures that can crash-loop gateway boot. Landed from contributor PR #27506 by @Sid-Qin. (#27455)
119120
- Telegram native commands: degrade command registration on `BOT_COMMANDS_TOO_MUCH` by retrying with fewer commands instead of crash-looping startup sync. Landed from contributor PR #27512 by @Sid-Qin. (#27456)
120121
- Web tools/Proxy: route `web_search` provider HTTP calls (Brave, Perplexity, xAI, Gemini, Kimi), redirect resolution, and `web_fetch` through a shared proxy-aware SSRF guard path so gateway installs behind `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY` no longer fail with transport `fetch failed` errors. (#27430) thanks @kevinWangSheng.

CONTRIBUTING.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ Welcome to the lobster tank! 🦞
5858

5959
- **Jonathan Taylor** - ACP subsystem, Gateway features/bugs, Gog/Mog/Sog CLI's, SEDMAT
6060
- Github [@visionik](https://github.com/visionik) · X: [@visionik](https://x.com/visionik)
61+
62+
- **Josh Lehman** - Compaction, Tlon/Urbit subsystem
63+
- Github [@jalehman](https://github.com/jalehman) · X: [@jlehman_](https://x.com/jlehman_)
6164

6265
## How to Contribute
6366

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
import type { OpenClawConfig } from "../config/config.js";
6+
import { getMemorySearchManager, type MemoryIndexManager } from "./index.js";
7+
import "./test-runtime-mocks.js";
8+
9+
const hoisted = vi.hoisted(() => ({
10+
providerCreateCalls: 0,
11+
providerDelayMs: 0,
12+
}));
13+
14+
vi.mock("./embeddings.js", () => ({
15+
createEmbeddingProvider: async () => {
16+
hoisted.providerCreateCalls += 1;
17+
if (hoisted.providerDelayMs > 0) {
18+
await new Promise((resolve) => setTimeout(resolve, hoisted.providerDelayMs));
19+
}
20+
return {
21+
requestedProvider: "openai",
22+
provider: {
23+
id: "mock",
24+
model: "mock-embed",
25+
maxInputTokens: 8192,
26+
embedQuery: async () => [0, 1, 0],
27+
embedBatch: async (texts: string[]) => texts.map(() => [0, 1, 0]),
28+
},
29+
};
30+
},
31+
}));
32+
33+
describe("memory manager cache hydration", () => {
34+
let workspaceDir = "";
35+
36+
beforeEach(async () => {
37+
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-concurrent-"));
38+
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
39+
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "Hello memory.");
40+
hoisted.providerCreateCalls = 0;
41+
hoisted.providerDelayMs = 50;
42+
});
43+
44+
afterEach(async () => {
45+
await fs.rm(workspaceDir, { recursive: true, force: true });
46+
});
47+
48+
it("deduplicates concurrent manager creation for the same cache key", async () => {
49+
const indexPath = path.join(workspaceDir, "index.sqlite");
50+
const cfg = {
51+
agents: {
52+
defaults: {
53+
workspace: workspaceDir,
54+
memorySearch: {
55+
provider: "openai",
56+
model: "mock-embed",
57+
store: { path: indexPath, vector: { enabled: false } },
58+
sync: { watch: false, onSessionStart: false, onSearch: false },
59+
},
60+
},
61+
list: [{ id: "main", default: true }],
62+
},
63+
} as OpenClawConfig;
64+
65+
const results = await Promise.all(
66+
Array.from(
67+
{ length: 12 },
68+
async () => await getMemorySearchManager({ cfg, agentId: "main" }),
69+
),
70+
);
71+
const managers = results
72+
.map((result) => result.manager)
73+
.filter((manager): manager is MemoryIndexManager => Boolean(manager));
74+
75+
expect(managers).toHaveLength(12);
76+
expect(new Set(managers).size).toBe(1);
77+
expect(hoisted.providerCreateCalls).toBe(1);
78+
79+
await managers[0].close();
80+
});
81+
});
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import fs from "node:fs/promises";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import type { DatabaseSync } from "node:sqlite";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6+
import type { OpenClawConfig } from "../config/config.js";
7+
import { resetEmbeddingMocks } from "./embedding.test-mocks.js";
8+
import type { MemoryIndexManager } from "./index.js";
9+
import { getRequiredMemoryIndexManager } from "./test-manager-helpers.js";
10+
11+
describe("memory manager readonly recovery", () => {
12+
let workspaceDir = "";
13+
let indexPath = "";
14+
let manager: MemoryIndexManager | null = null;
15+
16+
beforeEach(async () => {
17+
resetEmbeddingMocks();
18+
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-readonly-"));
19+
indexPath = path.join(workspaceDir, "index.sqlite");
20+
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
21+
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "Hello memory.");
22+
});
23+
24+
afterEach(async () => {
25+
if (manager) {
26+
await manager.close();
27+
manager = null;
28+
}
29+
await fs.rm(workspaceDir, { recursive: true, force: true });
30+
});
31+
32+
it("reopens sqlite and retries once when sync hits SQLITE_READONLY", async () => {
33+
const cfg = {
34+
agents: {
35+
defaults: {
36+
workspace: workspaceDir,
37+
memorySearch: {
38+
provider: "openai",
39+
model: "mock-embed",
40+
store: { path: indexPath },
41+
sync: { watch: false, onSessionStart: false, onSearch: false },
42+
},
43+
},
44+
list: [{ id: "main", default: true }],
45+
},
46+
} as OpenClawConfig;
47+
48+
manager = await getRequiredMemoryIndexManager({ cfg, agentId: "main" });
49+
50+
const runSyncSpy = vi.spyOn(
51+
manager as unknown as {
52+
runSync: (params?: { reason?: string; force?: boolean }) => Promise<void>;
53+
},
54+
"runSync",
55+
);
56+
runSyncSpy
57+
.mockRejectedValueOnce(new Error("attempt to write a readonly database"))
58+
.mockResolvedValueOnce(undefined);
59+
const openDatabaseSpy = vi.spyOn(
60+
manager as unknown as { openDatabase: () => DatabaseSync },
61+
"openDatabase",
62+
);
63+
64+
await manager.sync({ reason: "test" });
65+
66+
expect(runSyncSpy).toHaveBeenCalledTimes(2);
67+
expect(openDatabaseSpy).toHaveBeenCalledTimes(1);
68+
expect(manager.status().custom?.readonlyRecovery).toEqual({
69+
attempts: 1,
70+
successes: 1,
71+
failures: 0,
72+
lastError: "attempt to write a readonly database",
73+
});
74+
});
75+
76+
it("reopens sqlite and retries when readonly appears in error code", async () => {
77+
const cfg = {
78+
agents: {
79+
defaults: {
80+
workspace: workspaceDir,
81+
memorySearch: {
82+
provider: "openai",
83+
model: "mock-embed",
84+
store: { path: indexPath },
85+
sync: { watch: false, onSessionStart: false, onSearch: false },
86+
},
87+
},
88+
list: [{ id: "main", default: true }],
89+
},
90+
} as OpenClawConfig;
91+
92+
manager = await getRequiredMemoryIndexManager({ cfg, agentId: "main" });
93+
94+
const runSyncSpy = vi.spyOn(
95+
manager as unknown as {
96+
runSync: (params?: { reason?: string; force?: boolean }) => Promise<void>;
97+
},
98+
"runSync",
99+
);
100+
runSyncSpy
101+
.mockRejectedValueOnce({ message: "write failed", code: "SQLITE_READONLY" })
102+
.mockResolvedValueOnce(undefined);
103+
const openDatabaseSpy = vi.spyOn(
104+
manager as unknown as { openDatabase: () => DatabaseSync },
105+
"openDatabase",
106+
);
107+
108+
await manager.sync({ reason: "test" });
109+
110+
expect(runSyncSpy).toHaveBeenCalledTimes(2);
111+
expect(openDatabaseSpy).toHaveBeenCalledTimes(1);
112+
expect(manager.status().custom?.readonlyRecovery).toEqual({
113+
attempts: 1,
114+
successes: 1,
115+
failures: 0,
116+
lastError: "write failed",
117+
});
118+
});
119+
120+
it("does not retry non-readonly sync errors", async () => {
121+
const cfg = {
122+
agents: {
123+
defaults: {
124+
workspace: workspaceDir,
125+
memorySearch: {
126+
provider: "openai",
127+
model: "mock-embed",
128+
store: { path: indexPath },
129+
sync: { watch: false, onSessionStart: false, onSearch: false },
130+
},
131+
},
132+
list: [{ id: "main", default: true }],
133+
},
134+
} as OpenClawConfig;
135+
136+
manager = await getRequiredMemoryIndexManager({ cfg, agentId: "main" });
137+
138+
const runSyncSpy = vi.spyOn(
139+
manager as unknown as {
140+
runSync: (params?: { reason?: string; force?: boolean }) => Promise<void>;
141+
},
142+
"runSync",
143+
);
144+
runSyncSpy.mockRejectedValueOnce(new Error("embedding timeout"));
145+
const openDatabaseSpy = vi.spyOn(
146+
manager as unknown as { openDatabase: () => DatabaseSync },
147+
"openDatabase",
148+
);
149+
150+
await expect(manager.sync({ reason: "test" })).rejects.toThrow("embedding timeout");
151+
expect(runSyncSpy).toHaveBeenCalledTimes(1);
152+
expect(openDatabaseSpy).toHaveBeenCalledTimes(0);
153+
});
154+
});

0 commit comments

Comments
 (0)