From 6d6e8a841edba2f682250b267c1d832191998d77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 22 Aug 2026 17:26:55 +0000 Subject: [PATCH] spawned children are database rows, linked to their commander MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file was doing a job it is not good at. A newline-delimited list appended by a shell cannot express a link, cannot be queried, cannot be updated in place, and cannot hold a fact that arrives later — and a child's cost arrives later, from the child, long after the loop wrote its id. So the file becomes an INBOX and command_hq_spawned becomes the record. The constraint that shapes it: the loop is bash and cannot reach Postgres. Giving it a credential so it could would put an authenticated writer inside a background job, which is worse than the problem. So the loop appends with a shell redirect and the platform ingests — idempotently, on the unique index, which is what makes ingest-on-READ safe. A cron for this would be a moving part that can stop without anybody noticing; the read is the only moment the answer is wanted. What the rows buy that the file cannot: - the link, ENFORCED. onDelete cascade means deleting a commander cannot leave an orphaned child row, which is the reason this became important. - queries. Cost per commander, how many agents a trigger spawned, which are still running. - somewhere for finishedAt, totalUsd and outcome, which the loop cannot know when it appends. totalUsd is NULLABLE and null means unknown, never zero. The children endpoint returns `unknownCost` as a count beside the total, because a commander whose children's costs are unknown must not display a confident total that happens to be too low — that understatement is the whole reason this exists. Delete ingests before reading, so a child appended since the last read is still deleted: the file is allowed to be lossy, the rows are not, but a row never ingested does not exist yet. Co-Authored-By: Claude Opus 5 --- .../officer_db/src/command-hq/index.ts | 2 + .../officer_db/src/command-hq/queries.ts | 66 +++++++++++++++++++ .../officer_db/src/command-hq/schema.ts | 50 ++++++++++++++ src/servers/api/command-hq/router.ts | 38 ++++++++++- 4 files changed, 154 insertions(+), 2 deletions(-) diff --git a/src/databases/officer_db/src/command-hq/index.ts b/src/databases/officer_db/src/command-hq/index.ts index 47a99ef7..5c70d26f 100644 --- a/src/databases/officer_db/src/command-hq/index.ts +++ b/src/databases/officer_db/src/command-hq/index.ts @@ -14,6 +14,8 @@ export { recordCommandHqWatcherFired, disarmCommandHqWatcher, recordCommandHqTurn, + ingestSpawnedChildren, + recordSpawnedOutcome, getCommandHqCostSummary, listCommandHqTurns, listCommandHqWatcherFires, diff --git a/src/databases/officer_db/src/command-hq/queries.ts b/src/databases/officer_db/src/command-hq/queries.ts index 2786ab46..39e5903f 100644 --- a/src/databases/officer_db/src/command-hq/queries.ts +++ b/src/databases/officer_db/src/command-hq/queries.ts @@ -7,6 +7,7 @@ import { commandHqTags, commandHqTurns, commandHqWatcherFires, + commandHqSpawned, } from './schema'; // ── Categories ── @@ -405,3 +406,68 @@ export async function removeCommandHqSessionTag(sessionRowId: number, tagId: num .delete(commandHqSessionTags) .where(and(eq(commandHqSessionTags.sessionRowId, sessionRowId), eq(commandHqSessionTags.tagId, tagId))); } + +/* + Spawned children. The file is the inbox; these rows are the record — see the schema note. +*/ + +export type SpawnedChild = { + spawnedSessionId: string; + firstSeenAt: string; + finishedAt: string | null; + totalUsd: number | null; + outcome: string | null; +}; + +/** + * Ingest ids the loop appended, idempotently, and return the commander's children. + * + * One statement for the insert because the loop can repeat itself after a restart, so a duplicate append is + * expected rather than exceptional. `onConflictDoNothing` on the unique index makes re-ingesting the whole + * file cost nothing and change nothing, which is what lets the caller ingest on every read instead of + * running a cron that can silently stop. + */ +export async function ingestSpawnedChildren(sessionRowId: number, ids: string[]): Promise { + if (ids.length) { + await db + .insert(commandHqSpawned) + .values(ids.map((spawnedSessionId) => ({ sessionRowId, spawnedSessionId }))) + .onConflictDoNothing(); + } + const rows = await db + .select() + .from(commandHqSpawned) + .where(eq(commandHqSpawned.sessionRowId, sessionRowId)) + .orderBy(commandHqSpawned.firstSeenAt); + return rows.map((r) => ({ + spawnedSessionId: r.spawnedSessionId, + firstSeenAt: r.firstSeenAt.toISOString(), + finishedAt: r.finishedAt?.toISOString() ?? null, + totalUsd: r.totalUsd, + outcome: r.outcome, + })); +} + +/** + * What a child cost, once something knows. + * + * Separate from the ingest because the two facts arrive at different times from different parties: the loop + * knows the id at spawn, and only the child knows what it cost when it exits. Null cost stays null — an + * unknown total must not be reported as zero. + */ +export async function recordSpawnedOutcome( + sessionRowId: number, + spawnedSessionId: string, + outcome: { totalUsd?: number | null; outcome?: string | null }, +): Promise { + await db + .update(commandHqSpawned) + .set({ + finishedAt: new Date(), + ...(outcome.totalUsd === undefined ? {} : { totalUsd: outcome.totalUsd }), + ...(outcome.outcome === undefined ? {} : { outcome: outcome.outcome }), + }) + .where( + and(eq(commandHqSpawned.sessionRowId, sessionRowId), eq(commandHqSpawned.spawnedSessionId, spawnedSessionId)), + ); +} diff --git a/src/databases/officer_db/src/command-hq/schema.ts b/src/databases/officer_db/src/command-hq/schema.ts index d11ef069..d74a138a 100644 --- a/src/databases/officer_db/src/command-hq/schema.ts +++ b/src/databases/officer_db/src/command-hq/schema.ts @@ -195,3 +195,53 @@ export const commandHqSessionTags = pgTable( }, (table) => [uniqueIndex('uq_command_hq_session_tags').on(table.sessionRowId, table.tagId)], ); + +/** + * One row per agent a commander spawned. + * + * ── Why the database, when the loop writes a file ── + * + * The file is an INBOX, not the record. A commander's loop is bash: it can append a session id with a shell + * redirect and it cannot reach Postgres, and giving it a credential so it could would put an authenticated + * writer inside a background job — worse than the problem. So the loop appends, and the platform ingests + * into this table whenever anything asks. The file is allowed to be lossy; this is not. + * + * What the table buys that the file cannot: + * + * - the LINK, enforced. `onDelete: 'cascade'` means deleting a commander cannot leave orphaned children + * behind, which is the whole reason this became important. + * - queries. Cost per commander, how many agents a trigger spawned, which ones are still running — none of + * which a newline-delimited file can answer without reading every one. + * - a place for what the loop cannot know at append time: when the child finished, what it cost, whether + * it succeeded. Those arrive later, from the child itself, and a file appended by a shell cannot be + * updated in place. + * + * ── Why the child's own id is the key and not a serial ── + * + * Because the same child must not be ingested twice — the file is appended to by a loop that may repeat + * itself after a restart, and the ingest is idempotent on `(session_row_id, spawned_session_id)`. + */ +export const commandHqSpawned = pgTable( + 'command_hq_spawned', + { + id: serial('id').primaryKey(), + /** The commander. Cascades: its children go with it, which is the point. */ + sessionRowId: integer('session_row_id') + .notNull() + .references(() => commandHqSessions.id, { onDelete: 'cascade' }), + /** The child's own transcript id, as the harness wrote it. */ + spawnedSessionId: text('spawned_session_id').notNull(), + /** When the platform first saw it in the inbox — not when the child started, which nothing records. */ + firstSeenAt: timestamp('first_seen_at', { withTimezone: true }).notNull().defaultNow(), + /** + * Filled in later, by whoever finds out. Null means unknown rather than zero — a commander whose + * children's costs are unknown must not display a confident total that happens to be too low, which is + * the failure this table exists to end. + */ + finishedAt: timestamp('finished_at', { withTimezone: true }), + totalUsd: doublePrecision('total_usd'), + outcome: text('outcome'), + }, + // uniqueIndex, never unique().on() — drizzle-kit re-creates named composite constraints on every push. + (table) => [uniqueIndex('uq_command_hq_spawned').on(table.sessionRowId, table.spawnedSessionId)], +); diff --git a/src/servers/api/command-hq/router.ts b/src/servers/api/command-hq/router.ts index 88de742b..8bb9976b 100644 --- a/src/servers/api/command-hq/router.ts +++ b/src/servers/api/command-hq/router.ts @@ -5,7 +5,7 @@ import { checkBudget } from '../sub-agents/budget'; import * as errors from '@@/custom-errors'; import { resolveHomeDir } from '@@/user-home'; import { getCommandersDir, toShellUsername } from '@@/data-path'; -import { getChatEventsSince } from 'officerdb'; +import { getChatEventsSince, ingestSpawnedChildren } from 'officerdb'; import { clearSpawnedRecord, listSpawnedSessions, @@ -509,7 +509,11 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => { // 2. Everything it spawned. Best-effort per child: one transcript that has already been cleaned up must // not stop the rest from going, and a child is often gone before its parent. - const children = listSpawnedSessions(who.email, sessionId); + // Ingest the inbox before reading it, so a child appended since the last read is still deleted. The file + // is lossy by design and the rows are not — but a row that has never been ingested does not exist yet. + const children = (await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId))).map( + (c) => c.spawnedSessionId, + ); let childrenDeleted = 0; for (const child of children) { try { @@ -564,6 +568,36 @@ commandHqRouter.post('/commanders/:sessionId/tags', async (ctx) => { return ctx.json({ ok: true }); }); +/** + * GET /command-hq/commanders/:sessionId/children + * + * The agents this commander spawned, ingested from the loop's inbox on the way through. + * + * Ingesting on READ rather than on a timer is deliberate: a cron for this is a moving part that can stop + * without anybody noticing, and the read is the only moment the answer is wanted. `onConflictDoNothing` on + * the unique index makes re-ingesting the whole file cost nothing, so this is safe to call as often as the + * UI likes. + */ +commandHqRouter.get('/commanders/:sessionId/children', async (ctx) => { + const user = ctx.get('user'); + const who = await commandHqIdentity(user); + const sessionId = ctx.req.param('sessionId'); + + const row = await getCommandHqSession(user.id, sessionId); + if (!row) throw errors.NOT_FOUND('not a commander'); + + const children = await ingestSpawnedChildren(row.id, listSpawnedSessions(who.email, sessionId)); + // `unknownCost` said out loud rather than summed as zero: a commander whose children's costs are unknown + // must not display a confident total that happens to be too low. That understatement is the whole reason + // this exists. + const known = children.filter((c) => c.totalUsd !== null); + return ctx.json({ + children, + totalUsd: known.reduce((sum, c) => sum + (c.totalUsd ?? 0), 0), + unknownCost: children.length - known.length, + }); +}); + /** * GET /command-hq/commanders/:sessionId/record-paths *