deleting a conversation forgets what the platform stored about it
Found by a sanity check after two commanders were deleted through the UI: the Command HQ tables cascaded perfectly and four OTHER tables kept a row each, for ever. 24 orphaned rows in chat_session_models, 26 in chat_session_events, against 14 live transcripts. Nothing cascades because there is nothing to cascade FROM. All four are keyed on a transcript id, and a transcript is a file the harness writes — there is no row for a conversation, so the database cannot help and the delete path has to be explicit. It was not. Three of the four are inert: a model, a bell preference, a durable copy of turn output nothing will ask for again. `nudges` is not — it is a SCHEDULE, read by the tick in nudger.ts. A nudge belonging to a deleted conversation is a timer that keeps firing at something that no longer exists. That is what turned this from untidy into worth doing now. deleteClaudeSession returns the IDS it removed rather than a boolean, because a `/clear` chain is several transcripts presenting as one conversation and forgetting the head while orphaning its parts is the same bug at a smaller scale. THE TRAP THAT CHANGE INTRODUCED, caught before shipping: every call site said `if (deleteClaudeSession(...))` and an empty array is TRUTHY in JavaScript, so a delete that found nothing would have reported success. The typecheck was clean — `string[]` is a perfectly good truthy expression. All three sites test `.length` now. Fifth silently-wrong thing today that a typecheck could not see. Cleared the existing orphans with the same function the delete path now uses, after checking the one thing that would have made it wrong: whether any of them belonged to a member, whose transcripts live in a home this scan does not read. There is one account on this machine, so they were all the owner's. Two chat_session_events rows appeared again immediately after, and are not a leak: the durable log is written independently of the transcript — that is what makes turn output survive officer being down — so a session that has just begun legitimately has events before a transcript exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import { db } from './db';
|
||||
import { chatSessionEvents } from './chat-events/schema';
|
||||
import { chatNotificationPrefs } from './chat-notification-prefs/schema';
|
||||
import { chatSessionModels } from './chat-session-models/schema';
|
||||
import { nudges } from './nudges/schema';
|
||||
|
||||
/*
|
||||
Everything the platform stores ABOUT a conversation, forgotten together.
|
||||
|
||||
── Why this exists as one function ──
|
||||
|
||||
Four tables are keyed on a transcript id and none of them has a foreign key to anything, because the thing
|
||||
they describe is a FILE — a transcript on disk, written by the harness. There is no row for a chat session
|
||||
to reference, so the database cannot cascade and the delete path has to be explicit.
|
||||
|
||||
It was not. Deleting a conversation removed the transcript and left all four behind, for ever. Found on
|
||||
2026-08-22 by counting rows after two commanders were deleted through the UI: `chat_session_models` held 36
|
||||
rows against far fewer live conversations.
|
||||
|
||||
── Why it is more than untidy ──
|
||||
|
||||
Three of the four are inert — a model, a bell preference, a durable copy of turn output that nothing will
|
||||
ask for again. `nudges` is not: it is a SCHEDULE, and the tick in `nudger.ts` reads it. A nudge left behind
|
||||
by a deleted conversation is a timer that keeps firing at something that does not exist.
|
||||
|
||||
── Why it takes a list ──
|
||||
|
||||
A `/clear` chain is several transcripts presenting as one conversation, and deleting it deletes every part.
|
||||
Forgetting one id and leaving its siblings is the same bug at a smaller scale, so the caller passes the ids
|
||||
it actually removed.
|
||||
*/
|
||||
export async function forgetChatSessions(sessionIds: string[]): Promise<void> {
|
||||
if (!sessionIds.length) return;
|
||||
// Sequential rather than a transaction: these are independent tables with no invariant between them, and
|
||||
// a partial forget is strictly better than a failed delete that leaves the transcript gone and every row
|
||||
// in place. Each is idempotent — running it twice removes nothing the second time.
|
||||
await db.delete(nudges).where(inArray(nudges.sessionId, sessionIds));
|
||||
await db.delete(chatSessionModels).where(inArray(chatSessionModels.sessionId, sessionIds));
|
||||
await db.delete(chatNotificationPrefs).where(inArray(chatNotificationPrefs.sessionId, sessionIds));
|
||||
await db.delete(chatSessionEvents).where(inArray(chatSessionEvents.sessionId, sessionIds));
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export * from './nudges';
|
||||
export * from './ai-provider-usage';
|
||||
export * from './anthropic-accounts';
|
||||
export * from './theme-favorites';
|
||||
export { forgetChatSessions } from './chat-session-cleanup';
|
||||
export * from './command-hq';
|
||||
export * from './dashboards';
|
||||
export * from './integrations';
|
||||
|
||||
Reference in New Issue
Block a user