fix(sessions): enforce maintenance cap ownership (#110241)

This commit is contained in:
Patrick Erichsen
2026-07-17 16:52:17 -07:00
committed by GitHub
parent 1607da89b7
commit d7d358d3f6
3 changed files with 87 additions and 2 deletions

View File

@@ -1561,6 +1561,61 @@ describe("sqlite session normalization", () => {
).toEqual(["agent:main:newer", "agent:main:newest"]);
});
it("preserves an active SQLite cron entry when durable entries exceed maxEntries", async () => {
vi.mocked(getRuntimeConfig).mockReturnValue({
session: {
maintenance: {
mode: "enforce",
pruneAfter: "365d",
maxEntries: 1,
},
},
});
const env = { ...process.env, OPENCLAW_STATE_DIR: paths.stateDir };
const scopeFor = (sessionKey: string) => ({
agentId: "main",
env,
sessionKey,
storePath: paths.sqlitePath,
});
const cronKey = "agent:main:cron:job-1";
const cronEntry = {
lifecycleRevision: "cron-revision-1",
sessionId: "cron-session",
updatedAt: Date.now(),
};
for (const [sessionKey, sessionId] of [
["agent:main:slack:channel:C1", "channel-session-1"],
["agent:main:slack:channel:C2", "channel-session-2"],
] as const) {
await patchSqliteSessionEntry(
scopeFor(sessionKey),
() => ({ sessionId, updatedAt: Date.now() - 1 }),
{
fallbackEntry: { sessionId, updatedAt: Date.now() - 1 },
replaceEntry: true,
skipMaintenance: true,
},
);
}
await patchSqliteSessionEntry(scopeFor(cronKey), () => cronEntry, {
fallbackEntry: cronEntry,
replaceEntry: true,
});
await patchSqliteSessionEntry(scopeFor(cronKey), () => ({
model: "gpt-5.5",
updatedAt: Date.now() + 1,
}));
expect(loadSqliteSessionEntry(scopeFor(cronKey))).toMatchObject({
lifecycleRevision: "cron-revision-1",
model: "gpt-5.5",
sessionId: "cron-session",
});
});
it("evicts old SQLite transcript rows only when no remaining entry references them", async () => {
vi.mocked(getRuntimeConfig).mockReturnValue({
session: {

View File

@@ -531,14 +531,13 @@ function wouldCapActiveSession(params: {
*/
export function capEntryCount(
store: Record<string, SessionEntry>,
overrideMax?: number,
maxEntries: number,
opts: {
log?: boolean;
onCapped?: (params: { key: string; entry: SessionEntry }) => void;
preserveKeys?: ReadonlySet<string>;
} = {},
): number {
const maxEntries = overrideMax ?? resolveMaintenanceConfigFromInput().maxEntries;
const preservedCount = Object.entries(store).filter(([key, entry]) =>
shouldPreserveMaintenanceEntry({ key, entry, preserveKeys: opts.preserveKeys }),
).length;

View File

@@ -1483,6 +1483,37 @@ describe("Integration: saveSessionStore with pruning", () => {
expect(loaded).toHaveProperty("session-50");
});
it("updateSessionStore honors configured maxEntries without an explicit override", async () => {
const now = Date.now();
await fs.writeFile(
storePath,
JSON.stringify({
oldest: makeEntry(now - 2),
recent: makeEntry(now - 1),
}),
"utf-8",
);
mockLoadConfig.mockReturnValue({
session: {
maintenance: {
mode: "enforce",
pruneAfter: "365d",
maxEntries: 2,
},
},
});
await updateSessionStore(storePath, (store) => {
store.newest = makeEntry(now);
});
const loaded = loadSessionStore(storePath, { skipCache: true });
expect(Object.keys(loaded)).toHaveLength(2);
expect(loaded).toHaveProperty("newest");
expect(loaded).toHaveProperty("recent");
expect(loaded.oldest).toBeUndefined();
});
it("loadSessionStore honors configured maxEntries without an explicit override", async () => {
mockLoadConfig.mockReturnValue({
session: {