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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user