stop the harness before deleting its transcript, and wait for it to actually stop
Deleting a commander removed its transcript and the `claude` CLI wrote it straight back. Verified rather than inferred: the file returned with 22 lines, every one timestamped after the delete, and the deleted commander was sitting in /chat as "(untitled)" — no longer in `command_hq_sessions`, so nothing filtered it out. Delete a commander, get an untitled chat back. Two causes, and the first one hid the second. The kill never happened. `killClaudeSession` was imported straight from `claude-manager` into a route that runs in `officer`, while the session map lives in `officer-claude-code`. It read an empty map in the wrong process and returned false every time. `claude:kill` and `killClaude` already existed for this; the route just wasn't using them. And killing is not being dead. `abort()` returns while the CLI is still unwinding, and officer does not own that file — the CLI does. So a session now carries an `exited` promise resolved by the same exit hook that already reaps it, `claude:kill` replies only once that resolves, and both delete routes await the reply before touching the filesystem. Bounded at 5s and never fatal. A child that will not die delays a delete by seconds instead of hanging it, and a sidecar that is down leaves the caller with exactly the race it already had. `markExited` fires before `dropDeadSession`'s identity guard, deliberately: a waiter has to be released even when a newer session has taken its key, or it blocks for the full timeout on a process that died on schedule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -247,6 +247,18 @@ type PersistentSession = {
|
||||
interrupted: boolean;
|
||||
/** What we passed as `--resume`, so a failure can name it and decide whether it was to blame. */
|
||||
resumeId?: string;
|
||||
/**
|
||||
* Resolves when this session's child process is actually gone.
|
||||
*
|
||||
* Killing is not the same as being dead, and the difference is visible on disk: `abort()` returns at once
|
||||
* while the `claude` CLI keeps writing for as long as it takes to unwind. Officer does not own the
|
||||
* transcript — the CLI does — so a delete that races this window removes the file and the CLI writes it
|
||||
* straight back, resurrecting a conversation the user deleted. Anything that deletes a session's FILES
|
||||
* has to await this first.
|
||||
*/
|
||||
exited: Promise<void>;
|
||||
/** Called by the exit hook. Resolving twice is a no-op, which is what makes the double-fire safe. */
|
||||
markExited: () => void;
|
||||
/**
|
||||
* The model this session is currently answering as, `harness/tier`.
|
||||
*
|
||||
@@ -362,6 +374,10 @@ function spawnClaudeAsOwner({ command, args, cwd, env, signal }: SpawnOptions):
|
||||
* transcript by id.
|
||||
*/
|
||||
function dropDeadSession(session: PersistentSession, detail: string): void {
|
||||
// Before the identity guard, and deliberately: this fires on the real process exit, and a waiter must be
|
||||
// released even when a newer session has already taken this key. Otherwise a replaced session's waiter
|
||||
// blocks until its timeout for a process that died on schedule.
|
||||
session.markExited();
|
||||
// Identity, not key: a later turn may have already replaced this entry, and killing its session because
|
||||
// its predecessor's process exited would break the live conversation instead of a dead one.
|
||||
if (sessions.get(session.sessionKey) !== session) return;
|
||||
@@ -562,7 +578,13 @@ function createSession(
|
||||
interrupted: false,
|
||||
stderrTail: '',
|
||||
sawOutput: false,
|
||||
// Assigned immediately below — the resolver has to escape the executor before the object is usable.
|
||||
exited: undefined as unknown as Promise<void>,
|
||||
markExited: () => {},
|
||||
};
|
||||
session.exited = new Promise<void>((resolve) => {
|
||||
session.markExited = resolve;
|
||||
});
|
||||
|
||||
// Strip the nested-session guard vars so the SDK can spawn `claude` (mirrors the old spawn env clean).
|
||||
const cleanEnv = envWithoutParentSession();
|
||||
@@ -1236,6 +1258,33 @@ export async function interruptClaudeSession(sessionKey: string, userId: number)
|
||||
}
|
||||
|
||||
/** Fully tear the session down (the "disconnect" action / idle GC): abort the query + close input. */
|
||||
/**
|
||||
* Kill a session and wait for its child process to actually be gone.
|
||||
*
|
||||
* The bounded wait is the point. `killClaudeSession` returns as soon as it has aborted, which is before the
|
||||
* `claude` CLI has finished writing — and the CLI, not officer, owns the transcript file. A caller that
|
||||
* deletes files has to await this or it deletes them out from under a process still writing them.
|
||||
*
|
||||
* The timeout resolves rather than throws: a child that will not die within the window is a worse problem
|
||||
* than a stale transcript, and blocking a user's delete on it forever helps nobody. The caller gets `false`
|
||||
* and can say so.
|
||||
*/
|
||||
export async function killClaudeSessionAndWait(sessionKey: string, userId: number, timeoutMs = 5000): Promise<boolean> {
|
||||
const session = ownedSession(sessionKey, userId);
|
||||
if (!session) return false;
|
||||
const { exited } = session;
|
||||
killClaudeSession(sessionKey, userId);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timedOut = await Promise.race([
|
||||
exited.then(() => false),
|
||||
new Promise<boolean>((resolve) => {
|
||||
timer = setTimeout(() => resolve(true), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
if (timer) clearTimeout(timer);
|
||||
return !timedOut;
|
||||
}
|
||||
|
||||
export function killClaudeSession(sessionKey: string, userId: number): boolean {
|
||||
const session = ownedSession(sessionKey, userId);
|
||||
if (!session) return false;
|
||||
|
||||
@@ -294,11 +294,20 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'claude:kill':
|
||||
claudeManager.killClaudeSession(cmd.sessionKey, cmd.userId);
|
||||
case 'claude:kill': {
|
||||
/*
|
||||
Reply only once the child is actually gone.
|
||||
|
||||
The caller deleting a session's transcript has to know the CLI has stopped writing it, and this
|
||||
reply is the only signal it can get — officer's side of the socket has no view of the process.
|
||||
Bounded inside `killClaudeSessionAndWait`, so a child that will not die delays this by seconds
|
||||
rather than hanging the caller's request.
|
||||
*/
|
||||
await claudeManager.killClaudeSessionAndWait(cmd.sessionKey, cmd.userId);
|
||||
sessionLog.drop(cmd.sessionKey);
|
||||
reply({ type: 'claude:killed', id: cmd.id });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'claude:interrupt':
|
||||
await claudeManager.interruptClaudeSession(cmd.sessionKey, cmd.userId);
|
||||
|
||||
Reference in New Issue
Block a user