a commander records what it is and what it spawned, so deleting one means it
Without this record a commander cannot be stopped or fully deleted, and the delete route's comment claimed otherwise: "the running background process (if any) dies on its own". It does not. Nothing in that route touched a process, so a deleted commander left its loop running — still polling, still able to fire, still able to spawn a deploying agent, with no chip to stop it from and no row left to disarm. THE RECORD, and why the loop has to write it. Three ways to recover the link were tried and none works: the PROCESS holds nothing (no open transcript descriptor, nothing identifying in its environment — tested); the TRANSCRIPT is indistinguishable, because queue-operation/ai-title/last-prompt look like platform markers and are the harness's own, appearing in every session equally; CONTENT matching finds ordinary chats that quote a procedure as readily as the agent following it. So the loop records, because it is the only party that knows. Two files under DATA_PATH/<email>/commander-state/<session>/: `loop-pid` and `spawned`. The reader is deliberately tolerant — a truncated write or a stray line is dropped, not fatal, because a record that failed closed would make a commander undeletable over one bad line. STOPPING needs no new mechanism, only the pid: descendantPids + SIGTERM from the loop takes the loop and any agent under it, which is exactly what that helper was written for. A commander with no recorded pid stops nothing and SAYS so — the honest answer for one armed before this existed. DELETE now cascades in a deliberate order: stop the processes, then the children's transcripts, then the commander. Stopping first because a live agent writing to a transcript being deleted is a race, and because the loop could otherwise spawn a replacement between the last two steps. It returns counts rather than a bare ok — this is the one irreversible action here. The children's transcripts are deleted with the EXISTING deleteClaudeSession: its cwd argument is documented as "a hint, not an authority", so it locates a child that ran in the platform checkout rather than the commanders directory. No new function. THE CHAIN'S WEAK LINK was that the loop cannot know which commander it belongs to. The paths are now handed to it in the background-job prompt, fetched from a new endpoint because they are rooted in DATA_PATH — a server fact. Paths rather than an upload endpoint on purpose: a shell redirect needs no credential, and a background job has no business holding one. The prompt rule is numbered properly rather than landing on 7.5, and the output rule shifts down one when it is present. Verified both shapes produce consecutive numbers. The manager now exposes claudeSessionId, because a commander is keyed on the TRANSCRIPT id everywhere and officer's own session handle is not interchangeable — that mismatch is what once rendered a working commander as an empty pane. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,13 @@ import * as errors from '@@/custom-errors';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { getCommandersDir, toShellUsername } from '@@/data-path';
|
||||
import { getChatEventsSince } from 'officerdb';
|
||||
import {
|
||||
clearSpawnedRecord,
|
||||
listSpawnedSessions,
|
||||
loopPidPath,
|
||||
spawnedRecordPath,
|
||||
stopCommanderProcesses,
|
||||
} from './spawned';
|
||||
import type { ServerMessage } from '../chat/types';
|
||||
import {
|
||||
listClaudeSessions,
|
||||
@@ -469,11 +476,25 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
||||
* disk, so hide it" pattern `purgeCommandHqSessions` exists for — it is the one place that makes the two
|
||||
* agree by deleting the thing itself, on purpose, because the owner asked to.
|
||||
*
|
||||
* A watcher still armed at the time of deletion is left as-is rather than disarmed first: the transcript
|
||||
* is gone either way, `armCommandHqWatcher`/`recordCommandHqWatcherFired` can no longer find a row to
|
||||
* update once this returns, and the running background process (if any) dies on its own the same way it
|
||||
* would if the harness process were killed out from under any other conversation — not a new failure
|
||||
* mode this route has to account for.
|
||||
* ── It cascades, and it has to ──
|
||||
*
|
||||
* Deleting a commander means being finished with it and with everything it produced. So, in this order:
|
||||
*
|
||||
* 1. STOP the processes. The loop the commander started is a descendant of the commander's own `claude`,
|
||||
* and any agent the loop spawned is a descendant of the loop, so one `killTree` from the session's
|
||||
* process takes all of it. The comment here used to claim the background process "dies on its own" —
|
||||
* it does not. Nothing in this route touched a process, so a deleted commander left its loop running:
|
||||
* still polling, still able to fire, still able to spawn an agent, with no chip to stop it from and no
|
||||
* row left to disarm.
|
||||
* 2. DELETE the children's transcripts, read from the spawned record. They cannot be found any other way
|
||||
* — see `spawned.ts` for the three approaches that do not work.
|
||||
* 3. DELETE the commander's own transcript and row, as before.
|
||||
*
|
||||
* Stopping first is deliberate: a live agent writing to a transcript being deleted is a race, and the loop
|
||||
* could spawn a new one between steps 2 and 3.
|
||||
*
|
||||
* A watcher still armed is not disarmed first, and does not need to be — the row goes away entirely, and
|
||||
* `armCommandHqWatcher`/`recordCommandHqWatcherFired` can no longer find one to update.
|
||||
*/
|
||||
commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
@@ -483,9 +504,34 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||
const row = await getCommandHqSession(user.id, sessionId);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
// 1. Everything this commander is still running.
|
||||
const stopped = stopCommanderProcesses(who.email, sessionId);
|
||||
|
||||
// 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);
|
||||
let childrenDeleted = 0;
|
||||
for (const child of children) {
|
||||
try {
|
||||
// A spawned agent runs in the platform checkout, not the commanders directory, so its transcript
|
||||
// lives in that project's group — the same lookup `/chat/sessions/:id` falls back to.
|
||||
// The cwd is a HINT here, not an authority — `deleteClaudeSession` falls back to locating the
|
||||
// transcript wherever it actually is, which is what makes this work for a child that ran in the
|
||||
// platform checkout rather than the commanders directory.
|
||||
if (deleteClaudeSession(who, getCommandersDir(who.email), child)) childrenDeleted += 1;
|
||||
} catch {
|
||||
// Already gone, or never written. Counted as not-deleted rather than failing the request.
|
||||
}
|
||||
}
|
||||
clearSpawnedRecord(who.email, sessionId);
|
||||
|
||||
// 3. The commander itself.
|
||||
deleteClaudeSession(who, getCommandersDir(who.email), sessionId);
|
||||
await deleteCommandHqSession(user.id, sessionId);
|
||||
return ctx.json({ ok: true });
|
||||
|
||||
// Said back rather than a bare ok: this is the one irreversible action here, and the counts are what
|
||||
// make it auditable after the fact.
|
||||
return ctx.json({ ok: true, processesStopped: stopped, childrenFound: children.length, childrenDeleted });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -518,6 +564,29 @@ commandHqRouter.post('/commanders/:sessionId/tags', async (ctx) => {
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /command-hq/commanders/:sessionId/record-paths
|
||||
*
|
||||
* Where this commander's loop must write its pid and the session ids it spawns.
|
||||
*
|
||||
* The composer needs these to build the background-job prompt, and cannot derive them: they are rooted in
|
||||
* `DATA_PATH`, which is a server fact. Returning paths rather than accepting an upload is deliberate — the
|
||||
* loop writes them itself with a shell redirect, which needs no token and works from inside a background
|
||||
* job, where an authenticated POST would need a credential the loop has no business holding.
|
||||
*
|
||||
* Not gated on the commander existing as a ROW: a brand-new commander asks for these before its first turn
|
||||
* has filed it, and refusing then would mean the very first loop is the one that cannot record itself.
|
||||
*/
|
||||
commandHqRouter.get('/commanders/:sessionId/record-paths', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const sessionId = ctx.req.param('sessionId');
|
||||
return ctx.json({
|
||||
loopPidPath: loopPidPath(who.email, sessionId),
|
||||
spawnedPath: spawnedRecordPath(who.email, sessionId),
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /command-hq/commanders/:sessionId/tags/:tagId
|
||||
commandHqRouter.delete('/commanders/:sessionId/tags/:tagId', async (ctx: Context) => {
|
||||
const user = ctx.get('user');
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, appendFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { getCommanderStateDir } from '../../data-path';
|
||||
import { descendantPids } from '../tasks/process-tree';
|
||||
|
||||
/*
|
||||
What a commander spawned, and why the platform cannot work it out for itself.
|
||||
|
||||
A commander's action is a headless agent — `claude -p`, started by the shell loop the commander itself
|
||||
started. The platform never sees that spawn: it happens inside a background job, two processes below a
|
||||
conversation, and the agent talks to Anthropic rather than to us.
|
||||
|
||||
Three ways to recover the link were tried and none works:
|
||||
|
||||
- the PROCESS holds nothing. No open transcript descriptor (it appends and closes), and nothing
|
||||
identifying in its environment. Verified 2026-08-22.
|
||||
- the TRANSCRIPT is indistinguishable. `queue-operation`, `ai-title`, `last-prompt`, `atis-latch` look
|
||||
like markers the platform injects and are the harness's own, appearing in every session equally. A
|
||||
discriminator built on them rejected a real child and reported zero when one was running.
|
||||
- CONTENT matching finds the wrong sessions. An ordinary chat that happens to quote a procedure matches
|
||||
as well as the agent following it.
|
||||
|
||||
So the loop records it, and the platform reads it. One newline-delimited file per commander:
|
||||
|
||||
<DATA_PATH>/<email>/commander-state/<commander session id>/spawned
|
||||
|
||||
The loop can only do that if it knows which commander it belongs to, which it does not by default — the
|
||||
id is injected into the background-job prompt by `background-job-prompt.ts`. That is the whole chain, and
|
||||
every link is required.
|
||||
*/
|
||||
|
||||
const FILE = 'spawned';
|
||||
const PID_FILE = 'loop-pid';
|
||||
|
||||
/** A session id as the harness writes them. Anything else is a corrupt line and is dropped, not trusted. */
|
||||
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/** Where the loop should append. Handed to the agent in its prompt; nothing guesses it. */
|
||||
export function spawnedRecordPath(email: string, commanderSessionId: string): string {
|
||||
return join(getCommanderStateDir(email, commanderSessionId), FILE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every agent this commander recorded, oldest first, de-duplicated.
|
||||
*
|
||||
* Tolerant on purpose. The writer is a shell loop appending under whatever conditions it is in, so a
|
||||
* truncated write, a blank line or a stray log line must not break a delete — they are dropped and the rest
|
||||
* is used. A record that fails closed here would make a commander undeletable because of a bad line.
|
||||
*/
|
||||
export function listSpawnedSessions(email: string, commanderSessionId: string): string[] {
|
||||
const path = spawnedRecordPath(email, commanderSessionId);
|
||||
if (!existsSync(path)) return [];
|
||||
try {
|
||||
const seen = new Set<string>();
|
||||
for (const raw of readFileSync(path, 'utf-8').split('\n')) {
|
||||
const id = raw.trim();
|
||||
if (SESSION_ID.test(id)) seen.add(id.toLowerCase());
|
||||
}
|
||||
return [...seen];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one, idempotently. For the platform's own use — the loop appends directly, by design.
|
||||
*
|
||||
* Kept because the platform spawning an agent on a commander's behalf is the obvious next shape, and when
|
||||
* that arrives it must write the same file rather than a second record.
|
||||
*/
|
||||
export function recordSpawnedSession(email: string, commanderSessionId: string, spawnedId: string): void {
|
||||
if (!SESSION_ID.test(spawnedId)) return;
|
||||
if (listSpawnedSessions(email, commanderSessionId).includes(spawnedId.toLowerCase())) return;
|
||||
const dir = getCommanderStateDir(email, commanderSessionId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(join(dir, FILE), `${spawnedId.toLowerCase()}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
/** Drop the whole record. Called when the commander is deleted, after its children have been dealt with. */
|
||||
export function clearSpawnedRecord(email: string, commanderSessionId: string): void {
|
||||
try {
|
||||
rmSync(getCommanderStateDir(email, commanderSessionId), { recursive: true, force: true });
|
||||
} catch {
|
||||
// A record we cannot remove is untidy, not incorrect: the commander is gone and nothing reads this
|
||||
// without a commander to read it for.
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
── Stopping what a commander is running ──
|
||||
|
||||
The platform has no session-to-process map: `claude-manager` spawns through the SDK and keeps no pid, and
|
||||
a `claude` process cannot be attributed to a particular commander from outside — every commander's session
|
||||
runs with the same cwd, so the process table cannot tell them apart.
|
||||
|
||||
So the loop records its own pid, the same way it records what it spawns, and for the same reason: it is the
|
||||
only party that knows. From that one number the whole tree follows — the loop's children include any agent
|
||||
it spawned, and `descendantPids` was written for exactly this ("Bun's proc.kill() signals only the direct
|
||||
child, so a running grandchild is left orphaned and keeps going").
|
||||
|
||||
A commander with no recorded pid stops nothing and SAYS so. That is the honest answer for a commander armed
|
||||
before this existed, and pretending otherwise would report a clean delete over a loop still polling.
|
||||
*/
|
||||
|
||||
/** Where the loop should write its own pid. Handed to it in its prompt, like the spawned record. */
|
||||
export function loopPidPath(email: string, commanderSessionId: string): string {
|
||||
return join(getCommanderStateDir(email, commanderSessionId), PID_FILE);
|
||||
}
|
||||
|
||||
/** The loop's pid, if it recorded one and the process is still alive. */
|
||||
export function liveLoopPid(email: string, commanderSessionId: string): number | null {
|
||||
const path = loopPidPath(email, commanderSessionId);
|
||||
if (!existsSync(path)) return null;
|
||||
const pid = Number(readFileSync(path, 'utf-8').trim());
|
||||
if (!Number.isInteger(pid) || pid <= 1) return null;
|
||||
try {
|
||||
// Signal 0 tests for existence without touching it. A recorded pid whose process has gone is not an
|
||||
// error — loops end — and reusing a dead pid's number would kill something unrelated.
|
||||
process.kill(pid, 0);
|
||||
return pid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the loop and everything under it. Returns how many processes were signalled.
|
||||
*
|
||||
* Children first, then the loop, so the loop cannot spawn a replacement between the two.
|
||||
*/
|
||||
export function stopCommanderProcesses(email: string, commanderSessionId: string): number {
|
||||
const pid = liveLoopPid(email, commanderSessionId);
|
||||
if (!pid) return 0;
|
||||
const tree = [...descendantPids(pid), pid];
|
||||
let signalled = 0;
|
||||
for (const target of tree) {
|
||||
try {
|
||||
process.kill(target, 'SIGTERM');
|
||||
signalled += 1;
|
||||
} catch {
|
||||
// Gone between the walk and the signal. Not a failure — that is the outcome we wanted.
|
||||
}
|
||||
}
|
||||
return signalled;
|
||||
}
|
||||
Reference in New Issue
Block a user