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:
@@ -417,6 +417,16 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
|
||||
return ctx.json({ ok: true });
|
||||
}
|
||||
|
||||
/*
|
||||
Stop the harness BEFORE removing its file, and wait for it.
|
||||
|
||||
The `claude` CLI owns the transcript. Deleting it while a live session is still writing removes a file
|
||||
that is written straight back, so the conversation reappears untitled on the next refresh — see
|
||||
`killClaudeAndWait`. No live session means nothing to wait for, which is the ordinary case here.
|
||||
*/
|
||||
const liveKey = sessionKeyForTranscript(id);
|
||||
if (liveKey) await sidecar.killClaudeAndWait(liveKey, ctx.get('user').id);
|
||||
|
||||
// `.length`, not truthiness: this returns the IDS it removed and an empty array is truthy in JavaScript,
|
||||
// so `if (removed)` would report a delete that found nothing as a success.
|
||||
const removed = deleteClaudeSession(who, cwd, id);
|
||||
@@ -431,7 +441,6 @@ chatRouter.delete('/sessions/:id', async (ctx) => {
|
||||
`chat_session_events` is keyed on officer's session key. Undefined here is the normal case for an old
|
||||
conversation — no live session holds it, so nothing is keyed on a key to remove.
|
||||
*/
|
||||
const liveKey = sessionKeyForTranscript(id);
|
||||
await forgetChatSessions({ transcriptIds: removed, sessionKeys: liveKey ? [liveKey] : [] });
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as errors from '@@/custom-errors';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { getCommandersDir, toShellUsername } from '@@/data-path';
|
||||
import { forgetChatSessions, getChatEventsSince, ingestSpawnedChildren } from 'officerdb';
|
||||
import { killClaudeSession } from '@@/sidecar/claude/claude-manager';
|
||||
import * as sidecar from '@@/sidecar-registry';
|
||||
import { findSessionKeyByClaudeSession } from '@@/sidecar/claude/state';
|
||||
import { sessionKeyForTranscript } from '../chat/websocket';
|
||||
import {
|
||||
@@ -560,7 +560,18 @@ commandHqRouter.delete('/commanders/:sessionId', async (ctx) => {
|
||||
// Same mapping problem as the briefing: the sidecar's map is not visible from here, so the platform's own
|
||||
// is asked first and the sidecar's is kept as a fallback for a session that predates this process.
|
||||
const sessionKey = sessionKeyForTranscript(sessionId) ?? findSessionKeyByClaudeSession(sessionId, user.id);
|
||||
const sessionKilled = sessionKey ? killClaudeSession(sessionKey, user.id) : false;
|
||||
/*
|
||||
Across the socket, and AWAITED — both halves matter.
|
||||
|
||||
`killClaudeSession` from `claude-manager` was called directly here, and it could only ever return false:
|
||||
the session map lives in the `officer-claude-code` process and this route runs in `officer`, so it was
|
||||
reading an empty map in the wrong process. The kill silently never happened.
|
||||
|
||||
Awaited because the next thing this route does is delete the transcript, and the `claude` CLI owns that
|
||||
file. Deleting it while the CLI is still unwinding removes a file that is written straight back — which
|
||||
is exactly how a deleted commander came back as an untitled ordinary chat.
|
||||
*/
|
||||
const sessionKilled = sessionKey ? await sidecar.killClaudeAndWait(sessionKey, user.id) : false;
|
||||
const stopped = stopCommanderProcesses(who.email, sessionId);
|
||||
|
||||
// 2. Everything it spawned. Best-effort per child: one transcript that has already been cleaned up must
|
||||
|
||||
Reference in New Issue
Block a user