spawned children are database rows, linked to their commander
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 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,8 @@ export {
|
||||
recordCommandHqWatcherFired,
|
||||
disarmCommandHqWatcher,
|
||||
recordCommandHqTurn,
|
||||
ingestSpawnedChildren,
|
||||
recordSpawnedOutcome,
|
||||
getCommandHqCostSummary,
|
||||
listCommandHqTurns,
|
||||
listCommandHqWatcherFires,
|
||||
|
||||
@@ -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<SpawnedChild[]> {
|
||||
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<void> {
|
||||
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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user