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:
2026-08-22 20:54:23 +00:00
co-authored by Claude Opus 5
parent a6e7f64ef2
commit cb51f35cad
6 changed files with 124 additions and 17 deletions
+33 -7
View File
@@ -464,19 +464,41 @@ function createMessageHandler(
Fire-and-forget, swallowed failure, same posture as `record`: a database hiccup here must not be a
reason a chat turn fails.
*/
// Whether the chosen name has actually reached the transcript. False until a rename RETURNS true — see
// the retry note inside `fileAsCommander`.
let commanderNamed = false;
const fileAsCommander = (): void => {
// TEMPORARY instrumentation, 2026-08-22. The chosen name has failed to reach the transcript twice, and
// reasoning about the path has been wrong twice. Remove once the cause is known.
logger.warn('[commander-name] fileAsCommander', {
commandHq,
transcriptId,
hasName: !!commanderName,
name: commanderName ?? null,
alreadyNamed: commanderNamed,
});
if (!commandHq || !transcriptId) return;
const userId = sessionManager.getSession(sessionId)?.userId;
if (!userId) return;
const filedId = transcriptId;
void createCommandHqSession(userId, filedId)
.then(async () => {
// The chosen name, applied once and only if one was chosen.
//
// `renameClaudeSession` writes a `summary` record, which is what sets `hasExplicitTitle` — and an
// explicit title is never overwritten by the harness's own `ai-title` or by the first-message
// fallback. So this wins permanently rather than racing the titling.
if (!commanderName?.trim()) return;
/*
The chosen name, applied once — and RETRIED, because the first attempt usually fails.
`fileAsCommander` runs on `session:claude`, which arrives at the START of a turn: the transcript
file may not be on disk yet, and `renameClaudeSession` returns false when it cannot find one. It
returns a boolean that nothing was checking, so the name was silently dropped and the conversation
kept the harness's own `ai-title` — which is exactly what the first live test showed.
So the attempt is recorded and retried at the end of the turn, by which point the file certainly
exists. Idempotent: once it succeeds, `commanderNamed` stops it repeating.
It still writes a `summary` record, which sets `hasExplicitTitle` — and an explicit title is never
overwritten by `ai-title` or by the first-message fallback. So it wins whenever it lands.
*/
if (!commanderName?.trim() || commanderNamed) return;
// The same identity resolution every chat route uses, rather than a second copy of it: it is what
// decides the home and the OS user, and a rename writes a file as that user.
const user = await getUserById(userId).catch(() => null);
@@ -487,7 +509,8 @@ function createMessageHandler(
// working directory now, so that path is usually wrong. `findTranscript` treats the cwd as a HINT
// and locates the transcript wherever it actually is — so this works either way, and passing a
// path we know is probably wrong would be the kind of thing that reads as intent later.
renameClaudeSession(who, who.home, filedId, commanderName.trim());
commanderNamed = renameClaudeSession(who, who.home, filedId, commanderName.trim());
logger.warn('[commander-name] rename attempt', { filedId, home: who.home, ok: commanderNamed });
})
.catch((err: unknown) => {
logger.warn('Could not file this session as a commander', {
@@ -554,6 +577,9 @@ function createMessageHandler(
record();
fileAsCommander();
}
// And retried on every result until the name lands. The first attempt runs before the transcript file
// exists, so the end of the first turn is the earliest a rename can succeed.
if (msg.type === 'result' && commandHq && !commanderNamed) fileAsCommander();
/*
The watcher loop — see `docs/command-hq.md` → "The watcher loop" and `handoffContract` in