a file with no turns in it is not a conversation

Four "(untitled) · 0 msgs" rows accumulated in the sessions list from one evening
of commander testing. They are transcripts containing only harness metadata —
last-prompt, ai-title, atis-latch — written after a session was deleted or before
it ever sent anything. Filtered at the list, not deleted on disk: the files belong
to the harness, and a cleanup racing it would be the same class of bug that
produced them.

Which is also the cause of two of the four. The delete path resolves officer's
session key from two in-memory maps, and both answer only for a session younger
than the process. After a restart neither does, so no kill is sent, nothing is
awaited, and the transcript is removed while the harness is still writing — which
writes it back. Same failure as the events cleanup, same durable answer: the event
rows carry both ids, so ask them when the maps cannot say.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 00:28:56 +00:00
co-authored by Claude Opus 5
parent 5c3ffa7a06
commit a51a969cd0
4 changed files with 50 additions and 4 deletions
@@ -1,4 +1,4 @@
import { inArray, sql } from 'drizzle-orm';
import { eq, inArray, sql } from 'drizzle-orm';
import { db } from './db';
import { chatSessionEvents } from './chat-events/schema';
import { chatNotificationPrefs } from './chat-notification-prefs/schema';
@@ -90,3 +90,24 @@ export async function forgetChatSessions({ transcriptIds, sessionKeys = [] }: Fo
await db.delete(chatSessionEvents).where(inArray(chatSessionEvents.sessionId, sessionKeys));
}
}
/**
* Officer's session key for a transcript, read from the durable event log.
*
* The in-memory maps (`sessionKeyForTranscript`, `findSessionKeyByClaudeSession`) only answer for a session
* younger than the process that is asking. After a restart both return undefined, and the delete path then
* skips the kill entirely — so the transcript is removed while the harness is still writing it, and the
* harness writes it straight back. A deleted commander reappears as an untitled stub.
*
* Every `result` event carries both ids in its payload, which survives any restart. Same trick as
* `forgetChatSessions`, and for the same reason: the rows are the only place the two id spaces are written
* down together.
*/
export async function sessionKeyForTranscriptId(transcriptId: string): Promise<string | undefined> {
const [row] = await db
.select({ sessionId: chatSessionEvents.sessionId })
.from(chatSessionEvents)
.where(eq(sql`${chatSessionEvents.event}->>'claudeSessionId'`, transcriptId))
.limit(1);
return row?.sessionId;
}
+1 -1
View File
@@ -39,7 +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 { forgetChatSessions, sessionKeyForTranscriptId } from './chat-session-cleanup';
export * from './command-hq';
export * from './dashboards';
export * from './integrations';