the platform keeps the commander's name

The name only ever lived in the transcript's summary record, which the harness
cannot write until the first turn ends. So the list showed the opening brief
until then, while the header beside it showed the name from local state, and
every fix for that was a cache-invalidation trick timed against somebody else's
write. Three attempts, all of them fixing the symptom.

`command_hq_sessions.name` is written when the commander is filed, before the
first turn produces anything, and the list and detail routes prefer it. Nullable,
so rows filed before the column fall back to the transcript. The rename still
happens — it is what titles the conversation everywhere OUTSIDE Command HQ — it
just is not the source of truth any more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 00:46:17 +00:00
co-authored by Claude Opus 5
parent a51a969cd0
commit 91bc4ac74f
4 changed files with 26 additions and 2 deletions
@@ -55,6 +55,8 @@ export async function deleteCommandHqCategory(userId: number, id: number): Promi
export type CommandHqSessionRow = {
id: number;
sessionId: string;
/** What the owner called it. Null for rows filed before the column existed — fall back to the transcript. */
name: string | null;
categoryId: number | null;
watcherArmed: boolean;
watcherTaskId: string | null;
@@ -67,6 +69,7 @@ export type CommandHqSessionRow = {
const SESSION_ROW_COLUMNS = {
id: commandHqSessions.id,
sessionId: commandHqSessions.sessionId,
name: commandHqSessions.name,
categoryId: commandHqSessions.categoryId,
watcherArmed: commandHqSessions.watcherArmed,
watcherTaskId: commandHqSessions.watcherTaskId,
@@ -84,10 +87,11 @@ export async function createCommandHqSession(
userId: number,
sessionId: string,
categoryId: number | null = null,
name: string | null = null,
): Promise<CommandHqSessionRow> {
const [row] = await db
.insert(commandHqSessions)
.values({ userId, sessionId, categoryId })
.values({ userId, sessionId, categoryId, name })
.onConflictDoNothing({ target: [commandHqSessions.userId, commandHqSessions.sessionId] })
.returning(SESSION_ROW_COLUMNS);
// `onConflictDoNothing` returns nothing on the conflict path, so the existing row is fetched explicitly —
@@ -86,6 +86,18 @@ export const commandHqSessions = pgTable(
* id spaces both agree on only sometimes.
*/
sessionId: text('session_id').notNull(),
/**
* What the owner called it, held here rather than only in the transcript's summary record.
*
* The summary is the harness's, and it can only be written once the transcript file exists — which is
* after the first turn. So a list reading the transcript showed the opening brief until that turn ended,
* while the header beside it showed the name from local state. Two names for one commander, and every
* fix for it was a cache-invalidation trick timed against somebody else's write.
*
* The row is written when the commander is filed, before any of that. Nullable for the rows that predate
* this, which fall back to the transcript.
*/
name: text('name'),
/** Nullable — a commander may be uncategorised. `onDelete: 'set null'`: deleting a category uncategorises its commanders rather than deleting them. */
categoryId: integer('category_id').references(() => commandHqCategories.id, { onDelete: 'set null' }),
/** Should the platform keep re-arming this commander's watcher on every fire? See the file docstring. */