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