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:
@@ -622,6 +622,13 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
|
||||
would lose the brand-new composer, which has no session yet.
|
||||
*/
|
||||
isCommander: params.context === 'command-hq' || params.isCommander === true,
|
||||
/*
|
||||
Claude's transcript id, which is what a commander is keyed on everywhere — the Command HQ row, the
|
||||
record of what it spawned, the delete cascade. `sessionId` above is OFFICER's handle for the same
|
||||
conversation and is not interchangeable; using it here would key a commander's record on an id nothing
|
||||
else knows, which is the bug that made a working commander render as an empty pane.
|
||||
*/
|
||||
claudeSessionId: chat.claudeSessionId,
|
||||
messages,
|
||||
historicalCount,
|
||||
streamingText,
|
||||
|
||||
@@ -32,6 +32,14 @@ export type FireBehavior = 'stop' | 'handoff' | 'keep-going';
|
||||
export type BackgroundJobSpec = {
|
||||
/** `command` runs verbatim; `prompt` is an intent the agent works out. A watcher is always `prompt`. */
|
||||
mode: 'command' | 'prompt';
|
||||
/**
|
||||
* Where a COMMANDER's loop must record itself. Absent for an ordinary chat's background job.
|
||||
*
|
||||
* Two paths, both handed over rather than guessed, because the loop cannot derive either: it does not know
|
||||
* which commander it belongs to, and nothing outside it knows its pid or what it spawns. Without this
|
||||
* record a commander cannot be stopped or fully deleted — see `api/command-hq/spawned.ts`.
|
||||
*/
|
||||
record?: { loopPidPath: string; spawnedPath: string };
|
||||
/** The literal command, or the intent. */
|
||||
body: string;
|
||||
/** Prose: what to wait for and what to do when it happens. Empty means no watcher. */
|
||||
@@ -141,9 +149,40 @@ export function buildBackgroundJobPrompt(spec: BackgroundJobSpec): string {
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
A commander's loop must make itself findable. Nothing else can: the platform has no session-to-process
|
||||
map, a spawned agent's id is not recoverable from its process or its transcript, and the loop is the only
|
||||
party that knows either. Without these two lines a commander cannot be stopped or deleted completely —
|
||||
its loop outlives it, still polling and still able to spawn.
|
||||
|
||||
Numbered after whatever came before, like the rules above, and only present when a record path was
|
||||
supplied — an ordinary chat's background job has no commander to belong to.
|
||||
*/
|
||||
// The number the NEXT rule would have taken, so nothing lands on a fraction and the output rule below
|
||||
// simply moves down one when this is present.
|
||||
const nextRuleNumber = watching ? 8 : spec.mode === 'command' ? 5 : 4;
|
||||
if (spec.record) {
|
||||
rules.push(
|
||||
``,
|
||||
`${nextRuleNumber}. RECORD YOURSELF, first thing, before the wait begins:`,
|
||||
``,
|
||||
` mkdir -p "$(dirname '${spec.record.loopPidPath}')"`,
|
||||
` echo $$ > '${spec.record.loopPidPath}'`,
|
||||
``,
|
||||
` And every time you spawn an agent, append its session id — the \`session_id\` on the`,
|
||||
` \`{"type":"system","subtype":"init"}\` event, which you are already parsing — one per line:`,
|
||||
``,
|
||||
` echo "<session_id>" >> '${spec.record.spawnedPath}'`,
|
||||
``,
|
||||
` This is not bookkeeping. It is the only record of what you are and what you started: the`,
|
||||
` platform cannot find your process, and a spawned agent's id is unrecoverable once it exits.`,
|
||||
` Without it, deleting this commander leaves you running.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Unconditional — every shape of job gets this, not only watchers. `watching`/`spec.mode` only decide
|
||||
// which number it lands on, matching whichever rules already came before it in this particular prompt.
|
||||
const outputRuleNumber = watching ? 8 : spec.mode === 'command' ? 5 : 4;
|
||||
const outputRuleNumber = nextRuleNumber + (spec.record ? 1 : 0);
|
||||
rules.push(
|
||||
``,
|
||||
`${outputRuleNumber}. Do not throw away the command's own output — no \`>/dev/null\`, no \`2>&1\` into a`,
|
||||
|
||||
@@ -35,6 +35,13 @@ type BackgroundJobDialogProps = {
|
||||
cwd: string;
|
||||
/** `armWatcher` is true only for a `handoff` watcher — the one case the caller needs to tell the server about. */
|
||||
onSubmit: (intent: string, preamble: string, armWatcher: boolean) => void;
|
||||
/**
|
||||
* Where a commander's loop must record its pid and what it spawns. Absent for any other chat.
|
||||
*
|
||||
* Passed in rather than derived here: the paths are rooted in the platform's data directory and keyed on
|
||||
* the commander's session id, neither of which a dialog knows.
|
||||
*/
|
||||
recordPaths?: { loopPidPath: string; spawnedPath: string };
|
||||
/**
|
||||
* Is this composer running inside a Command HQ commander?
|
||||
*
|
||||
@@ -55,6 +62,7 @@ export const BackgroundJobDialog = ({
|
||||
cwd,
|
||||
onSubmit,
|
||||
isCommander = false,
|
||||
recordPaths,
|
||||
}: BackgroundJobDialogProps) => {
|
||||
const [mode, setMode] = useState<'command' | 'prompt'>('command');
|
||||
const [body, setBody] = useState('');
|
||||
@@ -102,6 +110,10 @@ export const BackgroundJobDialog = ({
|
||||
onFire,
|
||||
duration: timed ? minutes : null,
|
||||
cwd,
|
||||
// Only a commander gets the record instruction: it is what makes stopping and deleting one possible,
|
||||
// and an ordinary chat's background job has no commander to belong to. `record` is undefined until the
|
||||
// session has an id, which is the same moment it becomes a commander — see NewCommander.
|
||||
...(recordPaths ? { record: recordPaths } : {}),
|
||||
};
|
||||
// The intent is what the transcript shows; the preamble rides underneath it.
|
||||
onSubmit(
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useE
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
import { BackgroundJobDialog } from './BackgroundJobDialog';
|
||||
import { ClaudeLoginDialog } from './ClaudeLoginDialog';
|
||||
import { useCommanderRecordPaths } from '../useCommanderRecordPaths';
|
||||
import { armBackgroundJob } from '../../../background-job-origin';
|
||||
import { useWorkspace } from '../../../components/Workspace';
|
||||
import { AttachmentList } from './AttachmentList';
|
||||
@@ -19,6 +20,9 @@ type InputAreaProps = {
|
||||
|
||||
export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
// Only for a commander, and only once it has an id. Everything a commander's loop needs in order to be
|
||||
// stoppable and deletable hangs off these two paths — see api/command-hq/spawned.ts.
|
||||
const recordPaths = useCommanderRecordPaths(manager.isCommander ? manager.claudeSessionId : null);
|
||||
const {
|
||||
input,
|
||||
setInput,
|
||||
@@ -209,6 +213,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
|
||||
server. Only the radio button was missing, so the feature looked unbuilt.
|
||||
*/
|
||||
isCommander={manager.isCommander}
|
||||
recordPaths={recordPaths}
|
||||
onSubmit={(intent, preamble, armWatcher) => {
|
||||
// Claimed by the next job to start, which is how the preview tells this one apart from the
|
||||
// background jobs an agent starts on its own.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type RecordPaths = { loopPidPath: string; spawnedPath: string };
|
||||
|
||||
/**
|
||||
* Where this commander's loop must record its pid and what it spawns.
|
||||
*
|
||||
* Fetched rather than derived: both are rooted in `DATA_PATH`, a server fact. Cached indefinitely because
|
||||
* they are a pure function of the session id — nothing can change them but a different commander.
|
||||
*
|
||||
* Null for anything that is not a commander, and for a commander that has no id yet. The composer passes
|
||||
* whatever this returns straight into the background-job prompt, so absent means the loop is simply not
|
||||
* asked to record — correct for an ordinary chat, and the reason a commander must have its id before its
|
||||
* first background job.
|
||||
*/
|
||||
export function useCommanderRecordPaths(sessionId: string | null): RecordPaths | undefined {
|
||||
const client = useClient();
|
||||
const { data } = useQuery<RecordPaths>({
|
||||
queryKey: ['command-hq', 'record-paths', sessionId],
|
||||
queryFn: () => client.get<RecordPaths>(`/command-hq/commanders/${sessionId}/record-paths`),
|
||||
enabled: !!sessionId,
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
Reference in New Issue
Block a user