fix: reclaim orphan sqlite transcript rows

This commit is contained in:
Josh Lehman
2026-07-01 21:49:52 -07:00
parent bc25312213
commit 9fa268b143
3 changed files with 165 additions and 13 deletions

View File

@@ -1681,3 +1681,7 @@
{"type":"comment","timestamp":"2026-07-02T00:55:39.769131Z","issue_id":"clawdbot-d02.1.9.1.40-68a.2","payload":{"body":"Closeout clarification after autoreview: startup warning-tier is limited to cases where runtime state is preserved or safely absent. Malformed transcript imports now preserve the parseable prefix in SQLite and archive the original damaged file before startup continues. Whole-store failures such as store_unreadable/store_not_object remain blocking because there is no per-entry import proof."}}
{"type":"close","timestamp":"2026-07-02T01:21:12.283842Z","issue_id":"clawdbot-d02.1.9.1.40-68a.24","payload":{}}
{"type":"close","timestamp":"2026-07-02T02:24:40.211637Z","issue_id":"clawdbot-d02.1.9.1.40-68a.6","payload":{}}
{"type":"comment","timestamp":"2026-07-02T04:49:25.503304Z","issue_id":"clawdbot-d02.1.9.1.40-68a.5","payload":{"body":"Implemented reset/orphan-row reclaim follow-up on branch clawdbot-d02.1.9.1.40-68a.5/reset-orphan-row-sweep. SQLite lifecycle deletes now reclaim unreferenced transcript rows even when archive export is suppressed; maintenance sweeps pre-existing orphan session rows; mixed shared-session removals archive once if any removal requests archive; maintenance excludes rows already planned by the current lifecycle mutation. Validation: node scripts/run-vitest.mjs src/config/sessions/store.session-lifecycle-mutation.test.ts --reporter=dot, node scripts/run-vitest.mjs src/config/sessions/session-accessor.conformance.test.ts --reporter=dot, node scripts/check-session-accessor-boundary.mjs, git diff --check, autoreview clean."}}
{"type":"close","timestamp":"2026-07-02T04:49:26.067975Z","issue_id":"clawdbot-d02.1.9.1.40-68a.5","payload":{}}
{"type":"comment","timestamp":"2026-07-02T05:08:08.932705Z","issue_id":"clawdbot-d02.1.9.1.40-68a.5","payload":{"body":"Coordinator integration note: final PR integration narrowed the worker patch after autoreview. The unsafe generic sweep over every unreferenced sessions row was removed because raw transcript-only rows are a supported SQLite accessor state. Public sessions.delete/deleteTranscript:false behavior was also preserved. Landed scope reclaims unreferenced SQLite transcript/session rows for lifecycle removals whose removed entries own the session id, coalesces mixed archive plans, returns maintenance archive directories, and adds regression coverage that forced maintenance preserves raw transcript-only rows."}}
{"type":"close","timestamp":"2026-07-02T05:08:09.445959Z","issue_id":"clawdbot-d02.1.9.1.40-68a","payload":{}}

View File

@@ -804,6 +804,7 @@ export async function deleteSqliteSessionEntryLifecycle(
const deletePlans = params.archiveTranscript
? planSqliteSessionStateAfterEntryRemoval({
archiveDirectory: resolveSqliteTranscriptArchiveDirectory(resolved),
archiveTranscript: true,
database,
entry: current.entry,
reason: "deleted",
@@ -923,7 +924,11 @@ export async function applySqliteSessionEntryLifecycleMutation(params: {
materializedRemovalPlans,
);
}, toDatabaseOptions(resolved));
finalizeSqliteSessionEntryMaintenancePlansBestEffort(resolved, maintenancePlans);
const maintenanceArchivedTranscripts = finalizeSqliteSessionEntryMaintenancePlansBestEffort(
resolved,
maintenancePlans,
);
archivedTranscripts = [...archivedTranscripts, ...maintenanceArchivedTranscripts];
afterCount = readSqliteSessionEntryCount(
openOpenClawAgentDatabase(toDatabaseOptions(resolved)),
);
@@ -2919,6 +2924,7 @@ function materializeSqliteSessionStateDeletePlans(
// Multiple removed entries can point at one transcript session; dedupe before
// validation so the first row deletion does not stale a duplicate plan.
// If any owner asked to keep an archive, the shared row gets exported once.
function dedupeSqliteSessionStateDeletePlans(
plans: readonly SqliteSessionStateDeletePlan[],
): SqliteSessionStateDeletePlan[] {
@@ -2929,13 +2935,12 @@ function dedupeSqliteSessionStateDeletePlans(
deduped.set(plan.sessionId, plan);
continue;
}
if (
existing.archiveTranscript !== plan.archiveTranscript ||
existing.content !== plan.content ||
existing.reason !== plan.reason
) {
if (existing.content !== plan.content || existing.reason !== plan.reason) {
throw new Error(`Conflicting SQLite transcript archive plans for ${plan.sessionId}`);
}
if (!existing.archiveTranscript && plan.archiveTranscript) {
deduped.set(plan.sessionId, { ...existing, archiveTranscript: true });
}
}
return [...deduped.values()];
}
@@ -3003,6 +3008,7 @@ function deleteMaterializedSqliteSessionStatePlans(
// have projected which ids remain referenced.
function planSqliteSessionStateAfterEntryRemoval(params: {
archiveDirectory: string;
archiveTranscript?: boolean;
database: OpenClawAgentDatabase;
entry: SessionEntry;
reason: "deleted" | "reset";
@@ -3013,7 +3019,7 @@ function planSqliteSessionStateAfterEntryRemoval(params: {
const plans: SqliteSessionStateDeletePlan[] = [];
for (const sessionId of collectSqliteSessionStateIdsForEntry(params.entry)) {
const plan = planSqliteSessionStateDeleteIfUnreferenced({
archiveTranscript: true,
archiveTranscript: params.archiveTranscript,
archiveDirectory: params.archiveDirectory,
database: params.database,
reason: params.reason,
@@ -3038,7 +3044,7 @@ async function projectSqliteSessionEntryLifecycleMutation(
},
): Promise<SqliteProjectedLifecycleMutation> {
const store = readSqliteSessionEntryStore(database);
const removedEntries: SessionEntry[] = [];
const removedEntries: Array<{ archiveTranscript: boolean; entry: SessionEntry }> = [];
const changedSessionKeys = new Set<string>();
const projectedRemovals: SqliteProjectedLifecycleMutation["removals"] = [];
for (const removal of params.removals) {
@@ -3052,9 +3058,10 @@ async function projectSqliteSessionEntryLifecycleMutation(
removal,
sessionKey,
});
if (removal.archiveRemovedTranscript === true) {
removedEntries.push(entry);
}
removedEntries.push({
archiveTranscript: removal.archiveRemovedTranscript === true,
entry,
});
changedSessionKeys.add(sessionKey);
delete store[sessionKey];
}
@@ -3086,9 +3093,10 @@ async function projectSqliteSessionEntryLifecycleMutation(
excludedSessionKeys: changedSessionKeys,
projectedStore: store,
});
const deletePlans = removedEntries.flatMap((entry) =>
const deletePlans = removedEntries.flatMap(({ archiveTranscript, entry }) =>
planSqliteSessionStateAfterEntryRemoval({
archiveDirectory: params.archiveDirectory,
archiveTranscript,
database,
entry,
reason: "deleted",

View File

@@ -13,6 +13,7 @@ import type { DB as OpenClawAgentKyselyDatabase } from "../../state/openclaw-age
import { openOpenClawAgentDatabase } from "../../state/openclaw-agent-db.js";
import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
import {
applySessionEntryLifecycleMutation,
cleanupSessionLifecycleArtifacts,
deleteSessionEntryLifecycle,
loadTranscriptEvents,
@@ -368,7 +369,7 @@ describe("session store lifecycle mutations", () => {
}
});
it("deletes a SQLite entry without archiving transcripts when archiveTranscript is false", async () => {
it("deletes a SQLite entry without deleting transcripts when archiveTranscript is false", async () => {
const now = Date.now();
await replaceSessionEntry(
{ sessionKey: "agent:main:delete-entry-only", storePath },
@@ -408,6 +409,138 @@ describe("session store lifecycle mutations", () => {
).resolves.toEqual([createTranscriptEvent("entry-only-session", "preserve transcript rows")]);
});
it("deletes SQLite transcript rows for non-archived lifecycle removals", async () => {
const now = Date.now();
const entry: SessionEntry = {
sessionId: "lifecycle-remove-no-archive-session",
updatedAt: now,
};
await replaceSessionEntry({ sessionKey: "agent:main:no-archive-removal", storePath }, entry);
await replaceSqliteTranscriptEvents(
{
sessionKey: "agent:main:no-archive-removal",
sessionId: "lifecycle-remove-no-archive-session",
storePath,
},
[createTranscriptEvent("lifecycle-remove-no-archive-session", "remove rows without archive")],
);
const result = await applySessionEntryLifecycleMutation({
storePath,
removals: [
{
sessionKey: "agent:main:no-archive-removal",
expectedEntry: entry,
archiveRemovedTranscript: false,
},
],
maintenanceOverride: { mode: "enforce" },
});
expect(result.removedSessionKeys).toEqual(["agent:main:no-archive-removal"]);
expect(result.archivedTranscriptDirectories).toEqual([]);
expect(
readArchiveNames(
path.dirname(storePath),
"lifecycle-remove-no-archive-session.jsonl.deleted.",
),
).toEqual([]);
await expect(
loadTranscriptEvents({
sessionKey: "agent:main:no-archive-removal",
sessionId: "lifecycle-remove-no-archive-session",
storePath,
}),
).resolves.toEqual([]);
});
it("archives shared SQLite transcript rows when any lifecycle removal requests archive", async () => {
const now = Date.now();
const entry: SessionEntry = {
sessionId: "mixed-archive-shared-session",
updatedAt: now,
};
await replaceSessionEntry({ sessionKey: "agent:main:mixed-archive-a", storePath }, entry);
await replaceSessionEntry({ sessionKey: "agent:main:mixed-archive-b", storePath }, entry);
await replaceSqliteTranscriptEvents(
{
sessionKey: "agent:main:mixed-archive-a",
sessionId: "mixed-archive-shared-session",
storePath,
},
[createTranscriptEvent("mixed-archive-shared-session", "shared mixed archive")],
);
const result = await applySessionEntryLifecycleMutation({
storePath,
removals: [
{
sessionKey: "agent:main:mixed-archive-a",
expectedEntry: entry,
archiveRemovedTranscript: false,
},
{
sessionKey: "agent:main:mixed-archive-b",
expectedEntry: entry,
archiveRemovedTranscript: true,
},
],
skipMaintenance: true,
});
expect(result.removedSessionKeys).toEqual([
"agent:main:mixed-archive-a",
"agent:main:mixed-archive-b",
]);
expect(result.archivedTranscriptDirectories).toEqual([path.dirname(storePath)]);
const archiveNames = readArchiveNames(
path.dirname(storePath),
"mixed-archive-shared-session.jsonl.deleted.",
);
expect(archiveNames).toHaveLength(1);
expect(readArchiveLines(path.join(path.dirname(storePath), archiveNames[0] ?? ""))).toEqual([
createTranscriptEventLine("mixed-archive-shared-session", "shared mixed archive"),
]);
await expect(
loadTranscriptEvents({
sessionKey: "agent:main:mixed-archive-a",
sessionId: "mixed-archive-shared-session",
storePath,
}),
).resolves.toEqual([]);
});
it("forced maintenance preserves raw SQLite transcript-only rows", async () => {
await replaceSqliteTranscriptEvents(
{
sessionKey: "agent:main:raw-maintenance",
sessionId: "raw-maintenance-session",
storePath,
},
[createTranscriptEvent("raw-maintenance-session", "raw transcript-only row")],
);
const result = await applySessionEntryLifecycleMutation({
storePath,
activeSessionKey: "agent:main:raw-maintenance",
maintenanceOverride: { mode: "enforce" },
});
expect(result.archivedTranscriptDirectories).toEqual([]);
expect(
readArchiveNames(path.dirname(storePath), "raw-maintenance-session.jsonl.deleted."),
).toEqual([]);
await expect(
loadTranscriptEvents({
sessionKey: "agent:main:raw-maintenance",
sessionId: "raw-maintenance-session",
storePath,
}),
).resolves.toEqual([
createTranscriptEvent("raw-maintenance-session", "raw transcript-only row"),
]);
});
it("preserves shared SQLite transcript rows until the final session reference is deleted", async () => {
const now = Date.now();
await replaceSessionEntry(
@@ -554,6 +687,13 @@ function readArchiveLines(archivePath: string | undefined): string[] {
.split("\n");
}
function readArchiveNames(archiveDirectory: string, prefix: string): string[] {
if (!fs.existsSync(archiveDirectory)) {
return [];
}
return fs.readdirSync(archiveDirectory).filter((file) => file.startsWith(prefix));
}
function readArchiveLinesForSession(
result: { archivedTranscripts: Array<{ archivedPath: string }> },
sessionId: string,