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:
@@ -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. */
|
||||
|
||||
@@ -474,7 +474,10 @@ function createMessageHandler(
|
||||
const userId = sessionManager.getSession(sessionId)?.userId;
|
||||
if (!userId) return;
|
||||
const filedId = transcriptId;
|
||||
void createCommandHqSession(userId, filedId)
|
||||
// The name goes in with the row, not only to the transcript: the list reads it from here and is
|
||||
// right immediately, rather than waiting on a summary record the harness cannot write until the
|
||||
// first turn has finished.
|
||||
void createCommandHqSession(userId, filedId, null, commanderName?.trim() || null)
|
||||
.then(async () => {
|
||||
/*
|
||||
The chosen name, applied once — and RETRIED, because the first attempt usually fails.
|
||||
|
||||
@@ -248,6 +248,10 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
const commanders: CommanderSummary[] = filed
|
||||
.map((row) => ({
|
||||
...liveById.get(row.sessionId)!,
|
||||
// The row's name wins over the transcript's. It is written when the commander is filed, while the
|
||||
// summary record cannot exist until the first turn ends — so until then the transcript still reports
|
||||
// the opening brief, and the row is the only source that is right from the start.
|
||||
...(row.name ? { title: row.name } : {}),
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running: runningById.get(row.sessionId) ?? false,
|
||||
@@ -393,6 +397,7 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
|
||||
]);
|
||||
return ctx.json({
|
||||
sessionId: row.sessionId,
|
||||
name: row.name,
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running,
|
||||
|
||||
Reference in New Issue
Block a user