Skip to content

Commit 568ce44

Browse files
committed
fix(sessions): align behavioral suites and flip proof with retained history, route-aware cleanup plans
1 parent a015d35 commit 568ce44

9 files changed

Lines changed: 63 additions & 35 deletions

src/config/sessions/session-accessor.conformance.test.ts

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,12 @@ describe.each([publicAccessorAdapter, sqliteAdapter])(
441441
orphanTranscriptMinAgeMs: 300_000,
442442
nowMs,
443443
}),
444-
).resolves.toEqual({ removedEntries: 2, archivedTranscriptArtifacts: 2 });
444+
).resolves.toEqual({
445+
// Only the removed entry's transcript is archived: the orphan's route
446+
// row still targets it, and route-referenced history is retained.
447+
removedEntries: 2,
448+
archivedTranscriptArtifacts: 1,
449+
});
445450

446451
expect(
447452
adapter.loadSessionEntry(scopedEntry("agent:main:lifecycle-cleanup-missing")),
@@ -500,7 +505,11 @@ describe.each([publicAccessorAdapter, sqliteAdapter])(
500505
file.startsWith("orphan-lifecycle.jsonl.deleted."),
501506
);
502507
expect(removedArchive).toBeDefined();
503-
expect(orphanArchive).toBeDefined();
508+
// Route-referenced orphan history is retained in SQLite, not archived.
509+
expect(orphanArchive).toBeUndefined();
510+
await expect(
511+
adapter.loadTranscriptEvents(scopedTranscript("agent:main:orphan", "orphan-lifecycle")),
512+
).resolves.not.toEqual([]);
504513
expect(
505514
readSessionArchiveContentSync(
506515
path.join(path.dirname(cleanupStorePath), removedArchive ?? ""),
@@ -1616,7 +1625,7 @@ describe("sqlite session normalization", () => {
16161625
});
16171626
});
16181627

1619-
it("evicts old SQLite transcript rows only when no remaining entry references them", async () => {
1628+
it("keeps live entries and transcripts under byte pressure at save time", async () => {
16201629
vi.mocked(getRuntimeConfig).mockReturnValue({
16211630
session: {
16221631
maintenance: {
@@ -1697,6 +1706,9 @@ describe("sqlite session normalization", () => {
16971706
modelOverride: "gpt-5.5",
16981707
}));
16991708

1709+
// Live sessions are never save-time budget victims: byte pressure is
1710+
// handled by the async physical-budget pass, which only reclaims
1711+
// historical generations no entry, route, or admission references.
17001712
expect(
17011713
listSqliteSessionEntries({
17021714
agentId: "main",
@@ -1705,7 +1717,7 @@ describe("sqlite session normalization", () => {
17051717
})
17061718
.map((summary) => summary.sessionKey)
17071719
.toSorted(),
1708-
).toEqual(["agent:main:active-budget"]);
1720+
).toEqual(["agent:main:active-budget", "agent:main:old-budget", "agent:main:unshared-budget"]);
17091721
await expect(
17101722
loadSqliteTranscriptEvents({
17111723
agentId: "main",
@@ -1725,18 +1737,14 @@ describe("sqlite session normalization", () => {
17251737
sessionId: "unshared-budget-session",
17261738
storePath: paths.sqlitePath,
17271739
}),
1728-
).resolves.toEqual([]);
1729-
const archivedOldBudget = fs
1730-
.readdirSync(paths.tempDir)
1731-
.filter((file) => file.startsWith("old-budget-session.jsonl.deleted."));
1732-
expect(archivedOldBudget).toHaveLength(0);
1733-
const archivedUnsharedBudget = fs
1734-
.readdirSync(paths.tempDir)
1735-
.filter((file) => file.startsWith("unshared-budget-session.jsonl.deleted."));
1736-
expect(archivedUnsharedBudget).toHaveLength(1);
1740+
).resolves.toEqual([
1741+
expect.objectContaining({
1742+
id: "unshared-budget-event",
1743+
}),
1744+
]);
17371745
expect(
1738-
readSessionArchiveContentSync(path.join(paths.tempDir, archivedUnsharedBudget[0] ?? "")),
1739-
).toContain("unshared-budget-event");
1746+
fs.readdirSync(paths.tempDir).filter((file) => file.includes(".jsonl.deleted.")),
1747+
).toEqual([]);
17401748
});
17411749

17421750
it("resolves confirmed lowercased legacy SQLite session aliases", async () => {

src/config/sessions/session-accessor.sqlite-lifecycle-state.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,18 @@ export function collectProjectedReferencedSqliteSessionIds(params: {
419419
for (const sessionId of collectReferencedSqliteSessionIdsFromStore(params.projectedStore)) {
420420
sessionIds.add(sessionId);
421421
}
422+
// Routes protect their target session unless the cleanup removes that key's
423+
// route in the same pass; mirroring the post-cleanup state here keeps the
424+
// plan from writing archives for sessions the delete stage will retain.
425+
const routeRows = executeSqliteQuerySync(
426+
params.database.db,
427+
db.selectFrom("session_routes").select(["session_id", "session_key"]),
428+
).rows;
429+
for (const row of routeRows) {
430+
if (!excludedSessionKeys.has(row.session_key)) {
431+
sessionIds.add(row.session_id);
432+
}
433+
}
422434
return sessionIds;
423435
}
424436

src/config/sessions/session-history-eviction.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,9 @@ describe("kickSessionHistoryDiskBudgetMaintenance", () => {
322322
});
323323
// Give the fire-and-forget pass a tick to settle; an under-budget store
324324
// must leave every session untouched.
325-
await new Promise((resolve) => setTimeout(resolve, 50));
325+
await new Promise((resolve) => {
326+
setTimeout(resolve, 50);
327+
});
326328
closeOpenClawAgentDatabasesForTest();
327329
fs.rmSync(tempDir, { recursive: true, force: true });
328330
});

src/config/sessions/session-history-eviction.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
measureSessionPhysicalDiskUsage,
1515
pruneSessionTranscriptArchivesToHighWater,
1616
type SessionDiskBudgetSweepResult,
17+
type SessionPhysicalDiskUsage,
1718
} from "./disk-budget.js";
1819
import { materializeSqliteSessionStateDeletePlans } from "./session-accessor.sqlite-archive.js";
1920
import { emitArchivedSqliteTranscriptUpdates } from "./session-accessor.sqlite-events.js";
@@ -305,7 +306,7 @@ async function enforceSessionHistoryMaintenanceSerialized(
305306
});
306307
const database = openOpenClawAgentDatabase(toDatabaseOptions(resolved));
307308
const archiveDirectory = resolveSqliteTranscriptArchiveDirectory(resolved);
308-
let usage = await runExclusiveSqliteSessionWrite(resolved, async () => {
309+
let usage: SessionPhysicalDiskUsage = await runExclusiveSqliteSessionWrite(resolved, async () => {
309310
reclaimSqliteFreePages(database);
310311
return await measureSessionPhysicalDiskUsage(params.storePath);
311312
});

src/gateway/server.sessions.reset-hooks.test.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -554,13 +554,10 @@ test("sessions.reset emits enriched session_end and session_start hooks", async
554554
expect(endEvent.sessionId).toBe("sess-main");
555555
expect(endEvent.sessionKey).toBe("agent:main:main");
556556
expect(endEvent.reason).toBe("new");
557-
expect(endEvent.transcriptArchived).toBe(true);
558-
const archivedSessionFile = expectStringWithPrefix(
559-
path.basename(expectStringValue(endEvent.sessionFile, "archived session file")),
560-
"sess-main.jsonl.reset.",
561-
"archived session file",
562-
);
563-
expect(archivedSessionFile).toContain(".jsonl.reset.");
557+
// Retained history: reset keeps the SQLite transcript searchable under the
558+
// same key, so nothing is archived and no reset artifact file exists.
559+
expect(endEvent.transcriptArchived).toBeUndefined();
560+
expect(endEvent.sessionFile).toBeUndefined();
564561
expect(endEvent.nextSessionId).toBe(startEvent.sessionId);
565562
expectMainHookContext(endContext, "sess-main");
566563
expect(startEvent.sessionKey).toBe("agent:main:main");

src/gateway/server.sessions.store-rpc.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -592,13 +592,15 @@ test("lists and patches session store via sessions.* RPC", async () => {
592592
const entryAfterReset = loadSessionEntry({ sessionKey: "agent:main:main", storePath });
593593
expect(entryAfterReset?.lastAccountId).toBe("work");
594594
expect(entryAfterReset?.lastThreadId).toBe("1737500000.123456");
595+
// Retained history: reset rotates the live session id but keeps the old
596+
// generation's transcript rows in SQLite.
595597
await expect(
596598
loadTranscriptRows({
597599
sessionId: "sess-main",
598600
sessionKey: "agent:main:main",
599601
storePath,
600602
}),
601-
).resolves.toEqual([]);
603+
).resolves.toHaveLength(3);
602604

603605
const badThinking = await directSessionReq("sessions.patch", {
604606
key: "agent:main:main",

test/helpers/sqlite-sessions-transcripts-flip-proof.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2492,12 +2492,14 @@ function validateCheckpointInvariants(
24922492
});
24932493
}
24942494
if (checkpoint.label === "after-sessions-reset") {
2495-
requireArchiveText(checkpoint, failures, {
2496-
description: "reset transcript archive",
2497-
includes: ["legacy hello", "sqlite user-facing send before reset"],
2498-
reason: "reset",
2499-
sessionId: context.legacySessionId,
2500-
});
2495+
// Retained history: reset rotates the live session id but keeps the old
2496+
// generation's SQLite rows searchable; no reset archive is produced.
2497+
if (findArchiveArtifact(checkpoint, { reason: "reset", sessionId: context.legacySessionId })) {
2498+
failures.push(`${checkpoint.label}: unexpected reset transcript archive`);
2499+
}
2500+
if (checkpoint.sqlite.transcriptEvents === 0) {
2501+
failures.push(`${checkpoint.label}: retained transcript rows missing after reset`);
2502+
}
25012503
}
25022504
if (checkpoint.label === "after-sessions-delete") {
25032505
requireArchiveText(checkpoint, failures, {

test/scripts/sqlite-sessions-transcripts-flip-proof.built-cli.e2e.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,14 @@ describe("SQLite sessions/transcripts flip built CLI proof", () => {
119119
const resetCheckpoint = report.checkpoints.find(
120120
(checkpoint) => checkpoint.label === "after-sessions-reset",
121121
);
122+
// Retained history: reset keeps the old generation's SQLite rows and
123+
// writes no reset archive artifact.
122124
const resetArchive = resetCheckpoint?.archiveArtifacts.find(
123125
(artifact) =>
124126
artifact.archiveReason === "reset" && artifact.archiveSessionId === report.legacySessionId,
125127
);
126-
expect(resetArchive?.messageTexts).toContain("legacy hello");
127-
expect(resetArchive?.messageTexts).toContain("sqlite user-facing send before reset");
128+
expect(resetArchive).toBeUndefined();
129+
expect(resetCheckpoint?.sqlite.transcriptEvents ?? 0).toBeGreaterThan(0);
128130
const sharedFirstCheckpoint = report.checkpoints.find(
129131
(checkpoint) => checkpoint.label === "after-shared-first-delete",
130132
);

test/scripts/sqlite-sessions-transcripts-flip-proof.e2e.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,14 @@ describe("SQLite sessions/transcripts flip proof harness", () => {
118118
const resetCheckpoint = report.checkpoints.find(
119119
(checkpoint) => checkpoint.label === "after-sessions-reset",
120120
);
121+
// Retained history: reset keeps the old generation's SQLite rows and
122+
// writes no reset archive artifact.
121123
const resetArchive = resetCheckpoint?.archiveArtifacts.find(
122124
(artifact) =>
123125
artifact.archiveReason === "reset" && artifact.archiveSessionId === report.legacySessionId,
124126
);
125-
expect(resetArchive?.messageTexts).toContain("legacy hello");
126-
expect(resetArchive?.messageTexts).toContain("sqlite user-facing send before reset");
127+
expect(resetArchive).toBeUndefined();
128+
expect(resetCheckpoint?.sqlite.transcriptEvents ?? 0).toBeGreaterThan(0);
127129
expect(
128130
report.checkpoints.some(
129131
(checkpoint) =>

0 commit comments

Comments
 (0)