fix(sessions): preserve pinned sessions during maintenance (#122990)

This commit is contained in:
Dallin Romney
2026-08-13 12:08:41 +07:00
committed by GitHub
parent 1e7a76f49c
commit f8fcfe34b9
3 changed files with 101 additions and 2 deletions
@@ -1660,6 +1660,77 @@ describe("sqlite session normalization", () => {
).toEqual(["agent:main:newer", "agent:main:newest"]);
});
it("preserves pinned SQLite entries and transcripts during write-triggered capping", async () => {
vi.mocked(getRuntimeConfig).mockReturnValue({
session: {
maintenance: {
mode: "enforce",
pruneAfter: "365d",
maxEntries: 2,
},
},
});
const env = { ...process.env, OPENCLAW_STATE_DIR: paths.stateDir };
const scopeFor = (sessionKey: string) => ({
agentId: "main",
env,
sessionKey,
storePath: paths.sqlitePath,
});
const pinnedKey = "agent:main:pinned-dashboard";
const pinnedSessionId = "pinned-dashboard-session";
const pinnedTranscriptEvent = {
id: "pinned-event",
timestamp: new Date().toISOString(),
type: "metadata",
};
await patchSessionEntryCore(
scopeFor(pinnedKey),
() => ({ sessionId: pinnedSessionId, updatedAt: 1, pinnedAt: 2 }),
{
fallbackEntry: { sessionId: pinnedSessionId, updatedAt: 1, pinnedAt: 2 },
replaceEntry: true,
skipMaintenance: true,
},
);
await appendTranscriptEvent(
{ ...scopeFor(pinnedKey), sessionId: pinnedSessionId },
pinnedTranscriptEvent,
);
await patchSessionEntryCore(
scopeFor("agent:main:recent-dashboard"),
() => ({ sessionId: "recent-dashboard-session", updatedAt: 3 }),
{
fallbackEntry: { sessionId: "recent-dashboard-session", updatedAt: 3 },
replaceEntry: true,
skipMaintenance: true,
},
);
await patchSessionEntryCore(
scopeFor("agent:main:maintenance-trigger"),
() => ({ sessionId: "maintenance-trigger-session", updatedAt: 4 }),
{
fallbackEntry: { sessionId: "maintenance-trigger-session", updatedAt: 4 },
replaceEntry: true,
},
);
expect(loadSessionEntry(scopeFor(pinnedKey))).toMatchObject({
pinnedAt: 2,
sessionId: pinnedSessionId,
});
await expect(
loadTranscriptEvents({
agentId: "main",
env,
sessionId: pinnedSessionId,
storePath: paths.sqlitePath,
}),
).resolves.toEqual([pinnedTranscriptEvent]);
});
it("preserves an admitted SQLite session when another session triggers maintenance", async () => {
vi.mocked(getRuntimeConfig).mockReturnValue({
session: {
+2 -2
View File
@@ -428,8 +428,8 @@ export function shouldPreserveMaintenanceEntry(params: {
entry: SessionEntry | undefined;
preserveKeys?: ReadonlySet<string>;
}): boolean {
// Archived sessions are user-shelved; only an explicit sessions.delete may remove them.
if (params.entry?.archivedAt !== undefined) {
// Archived and pinned sessions are user-retained; only an explicit user action may release them.
if (params.entry?.archivedAt !== undefined || params.entry?.pinnedAt !== undefined) {
return true;
}
// A model lock is durable harness ownership, not merely a UI restriction.
+28
View File
@@ -142,6 +142,20 @@ describe("pruneStaleEntries", () => {
expect(pruneStaleEntries(store, 30 * DAY_MS)).toBe(1);
expect(store.archived).toBeUndefined();
});
it("preserves pinned entries until they are unpinned", () => {
const now = Date.now();
const store = makeStore([
["pinned", { ...makeEntry(now - 31 * DAY_MS), pinnedAt: now - DAY_MS }],
]);
expect(pruneStaleEntries(store, 30 * DAY_MS)).toBe(0);
expect(store).toHaveProperty("pinned");
delete store.pinned?.pinnedAt;
expect(pruneStaleEntries(store, 30 * DAY_MS)).toBe(1);
expect(store.pinned).toBeUndefined();
});
});
describe("resolveQuotaSuspensionEntryMaintenance", () => {
@@ -710,6 +724,20 @@ describe("capEntryCount", () => {
expect(store.old).toBeUndefined();
});
it("preserves pinned sessions when capping", () => {
const now = Date.now();
const store = makeStore([
["pinned", { ...makeEntry(now - 10 * DAY_MS), pinnedAt: now - 5 * DAY_MS }],
["recent", makeEntry(now)],
["old", makeEntry(now - DAY_MS)],
]);
expect(capEntryCount(store, 2)).toBe(1);
expect(store).toHaveProperty("pinned");
expect(store).toHaveProperty("recent");
expect(store.old).toBeUndefined();
});
it("preserves runtime-provided pending subagent sessions when capping", () => {
const now = Date.now();
const childKey = "agent:main:subagent:child";